Source code for ralph.workspace.scope

"""Canonical workspace scope for the active Ralph run.

Provides ``WorkspaceScope``, the frozen dataclass that centralises all
workspace-root and allowed-directory decisions made at process startup. Every
component that needs to know where files live or which paths an agent may write
should read its values from a ``WorkspaceScope`` instance rather than calling
``Path.cwd()`` directly.

Key API:

- ``resolve_workspace_scope(start)`` - detect the active workspace from the
  filesystem. Walks upward from *start* (default: ``cwd()``) looking for a
  ``ralph-workflow.toml`` config file or a git repo root. Linked worktrees
  automatically inherit config from the main worktree unless the linked
  worktree has its own override.
- ``WorkspaceScope`` - frozen dataclass with ``root``, ``allowed_roots``,
  ``local_config_path``, and ``propagated_config_paths``. Use
  ``scope.resolve_agent_file(filename)`` to locate ``.agent/`` files with
  correct inheritance between linked and main worktrees.
- ``WorkspaceScope.for_same_workspace_worker(...)`` - builds a restricted
  scope for parallel workers that share a single checkout; the repo root is NOT
  added to allowed roots, enforcing that workers only write to their declared
  directories and their own worker namespace.

Config files searched (in order):
  ``ralph-workflow.toml``, ``agents.toml``, ``pipeline.toml``,
  ``artifacts.toml``, ``mcp.toml`` (all under ``.agent/`` in the workspace
  root).
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
    from collections.abc import Iterable

from ralph.git.operations import GitOperationError, find_main_worktree_root, find_repo_root

CONFIG_DIR_NAME = ".agent"
WORKSPACE_CONFIG_NAME = "ralph-workflow.toml"
_WORKSPACE_AGENT_FILENAMES = (
    "ralph-workflow.toml",
    "agents.toml",
    "pipeline.toml",
    "artifacts.toml",
    "mcp.toml",
)


def _canonicalize(path: Path | str) -> Path:
    return Path(path).expanduser().resolve()


[docs] @dataclass(frozen=True, init=False) class WorkspaceScope: """Single source of truth for workspace root and config inheritance.""" root: Path allowed_roots: tuple[Path, ...] local_config_path: Path propagated_config_paths: tuple[Path, ...] def __init__( self, root: Path | str, allowed_roots: Iterable[Path | str] | None = None, *, local_config_path: Path | str | None = None, propagated_config_paths: Iterable[Path | str] | None = None, ) -> None: canonical_root = _canonicalize(root) deduped_allowed: list[Path] = [canonical_root] for candidate in allowed_roots or (): canonical_candidate = _canonicalize(candidate) if canonical_candidate not in deduped_allowed: deduped_allowed.append(canonical_candidate) canonical_allowed = tuple(deduped_allowed) canonical_local_config = _canonicalize( local_config_path or _default_local_config_path(canonical_root) ) canonical_propagated_configs = tuple( _canonicalize(candidate) for candidate in propagated_config_paths or () ) object.__setattr__(self, "root", canonical_root) object.__setattr__(self, "allowed_roots", canonical_allowed) object.__setattr__(self, "local_config_path", canonical_local_config) object.__setattr__(self, "propagated_config_paths", canonical_propagated_configs)
[docs] def resolve_agent_file(self, filename: str) -> Path: """Resolve the effective .agent file for this workspace. Linked worktrees inherit defaults from the main worktree unless the current workspace has an explicit local override for that filename. """ local_agent_dir = self.root / CONFIG_DIR_NAME local_candidate = local_agent_dir / filename if local_candidate.exists(): return local_candidate inherited_candidate = self.local_config_path.parent / filename if inherited_candidate != local_candidate: return inherited_candidate if self.propagated_config_paths: propagated_candidate = self.propagated_config_paths[0].parent / filename if propagated_candidate != local_candidate: return propagated_candidate return local_candidate
[docs] def has_any_local_agent_override(self) -> bool: """Return True when the current workspace has any explicit .agent override.""" local_agent_dir = self.root / CONFIG_DIR_NAME return any((local_agent_dir / filename).exists() for filename in _WORKSPACE_AGENT_FILENAMES)
[docs] @classmethod def for_same_workspace_worker( cls, repo_root: Path, allowed_directories: tuple[str, ...], worker_namespace: Path, ) -> WorkspaceScope: """Build a worker-scoped view of the shared checkout. The root stays at ``repo_root`` (no per-worker root reassignment). Each allowed directory is resolved relative to ``repo_root``. The ``worker_namespace`` is always added so the worker can write its own artifacts, logs, and temporary outputs even when ``allowed_directories`` is narrow. A ``ValueError`` is raised when any entry escapes ``repo_root`` via ``..`` or an absolute path. This method bypasses the standard __init__ to avoid unconditionally adding ``repo_root`` to allowed_roots. Same-workspace workers must NOT have the repo root as an allowed root — they are restricted to only their declared edit areas plus their own worker namespace. Args: repo_root: Shared repository root (same for all parallel workers). allowed_directories: Relative subpaths the worker may edit. worker_namespace: Per-worker scratch directory (always allowed). Returns: WorkspaceScope with root=repo_root, allowed_roots restricted to the declared directories plus the worker namespace (repo root is NOT included). """ canonical_root = _canonicalize(repo_root) canonical_ns = _canonicalize(worker_namespace) allowed_roots: list[Path] = [] for ad in allowed_directories: if not ad: raise ValueError("allowed_directory must be non-empty") p = canonical_root / ad resolved = p.resolve() try: resolved.relative_to(canonical_root) except ValueError: raise ValueError( f"allowed_directory {ad!r} escapes repo_root {canonical_root}" ) from None allowed_roots.append(resolved) allowed_roots.append(canonical_ns) # Build the scope directly, bypassing __init__ to avoid unconditionally # adding canonical_root to allowed_roots. Same-workspace workers must # only have their specific allowed directories + worker namespace. scope = object.__new__(cls) object.__setattr__(scope, "root", canonical_root) object.__setattr__(scope, "allowed_roots", cast("tuple[Path, ...]", tuple(allowed_roots))) object.__setattr__(scope, "local_config_path", _default_local_config_path(canonical_root)) object.__setattr__(scope, "propagated_config_paths", ()) return scope
def _default_local_config_path(root: Path) -> Path: return root / CONFIG_DIR_NAME / WORKSPACE_CONFIG_NAME def _find_nearest_workspace_root(candidate: Path, repo_root: Path) -> Path: """Prefer the nearest Ralph workspace config between cwd and repo root.""" current = candidate.resolve() resolved_repo_root = repo_root.resolve() while True: if _default_local_config_path(current).exists(): return current if current == resolved_repo_root: return resolved_repo_root parent = current.parent if parent == current: return resolved_repo_root current = parent
[docs] def resolve_workspace_scope(start: Path | str | None = None) -> WorkspaceScope: """Resolve the active workspace scope. The workspace root remains the active checkout, but linked worktrees inherit default .agent config from the main checkout unless the linked worktree has an explicit local override file. """ candidate = Path.cwd() if start is None else Path(start) try: repo_root = find_repo_root(candidate) main_root = find_main_worktree_root(candidate) root = _find_nearest_workspace_root(candidate, repo_root) local_config_path = _default_local_config_path(root) propagated_configs: tuple[Path, ...] = () if main_root != repo_root: inherited_config_path = _default_local_config_path(main_root) if not local_config_path.exists(): local_config_path = inherited_config_path else: propagated_configs = (inherited_config_path,) return WorkspaceScope( root, local_config_path=local_config_path, propagated_config_paths=propagated_configs, ) except GitOperationError: return WorkspaceScope(candidate)
__all__ = ["WorkspaceScope", "resolve_workspace_scope"]