oc_chat
Loading...
Searching...
No Matches
discovery.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/discovery.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_storage_foundation.py"
6"""
7Registry location discovery.
8
9Purpose
10-------
11Resolve the SQLite registry location in the order specified by the
12orchestration registry design-lock document.
13
14Responsibilities
15----------------
16- Honor ``OC_AGENT_REGISTRY`` when it points at an absolute path.
17- Detect ``.option_c_workspace.json`` and derive the shared registry path.
18- Fall back through git common-dir and project-root locations.
19- Return workspace metadata needed by the init seed path.
20
21Diagnostics
22-----------
23Domain: OC_CHAT.MAIN
24Levels:
25 L2 - lifecycle events
26 L3 - semantic details
27 L4 - deep tracing
28
29Contracts
30---------
31- Discovery is read-only and does not create directories or files.
32- Environment override paths must be absolute.
33
34Dependencies
35------------
36- oc_diagnostics (structured debug logging via _dbg)
37- oc_chat.core._paths (workspace root fallback)
38"""
39from __future__ import annotations
40
41from dataclasses import dataclass
42import json
43import os
44from pathlib import Path
45import subprocess
46from typing import Mapping
47
48from oc_diagnostics import _dbg
49
50from oc_chat.core._paths import find_workspace_root
51
52_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
53
54REGISTRY_ENV_VAR = "OC_AGENT_REGISTRY"
55WORKER_ENV_VAR = "OC_AGENT_WORKER"
56WORKSPACE_MANIFEST = ".option_c_workspace.json"
57DEFAULT_REGISTRY_RELATIVE = Path(".option_c") / "orchestration" / "registry.sqlite"
58DEFAULT_ORCHESTRATION_RELATIVE = Path(".option_c") / "orchestration"
59DEFAULT_CHECKPOINT_NAME = "state.json"
60
61
62class WorkerRegistryError(RuntimeError):
63 """Fail-closed worker registry-discovery boundary error (Plan 142j R7).
64
65 Raised under ``OC_AGENT_WORKER=1`` when ``OC_AGENT_REGISTRY`` is unset/empty,
66 not absolute, or resolves OUTSIDE the current worktree's
67 ``.option_c/orchestration/`` boundary โ€” the cross-worktree contamination the
68 per-worktree resolved-path model exists to prevent. NOT a ยง8 source/regenerate
69 envelope condition (discovery runs before any source operation); the CLI
70 surfaces it as exit 2 + a stderr message naming both ``OC_AGENT_REGISTRY`` and
71 the worker context.
72 """
73
74
75@dataclass(frozen=True)
77 """Resolved registry path plus workspace metadata for init seeding."""
78
79 registry_path: Path
80 workspace_root: Path
81 git_common_dir: Path
82 checkpoint_path: Path
83
84
86 start: str | os.PathLike[str] | None = None,
87 env: Mapping[str, str] | None = None,
88) -> Path:
89 """Resolve the registry SQLite path using spec section 6.1 order."""
90
91 return discover_registry_location(start=start, env=env).registry_path
92
93
95 start: str | os.PathLike[str] | None = None,
96 env: Mapping[str, str] | None = None,
97 registry_path: str | os.PathLike[str] | None = None,
98 workspace_root: str | os.PathLike[str] | None = None,
99) -> RegistryLocation:
100 """Resolve registry path and metadata needed by ``init``."""
101
102 mapping = os.environ if env is None else env
103 anchor = _anchor_path(start)
104 resolved_workspace = _resolve_workspace_root(anchor, workspace_root)
105 _worker_registry_guard(mapping, resolved_workspace)
106
107 if registry_path is not None:
108 resolved_registry = _absolute_path(Path(registry_path), anchor)
109 else:
110 resolved_registry = _discover_path(anchor, mapping, resolved_workspace)
111
112 git_common_dir = _git_common_dir(anchor) or (resolved_workspace / ".git").resolve()
113 checkpoint_path = resolved_registry.parent / DEFAULT_CHECKPOINT_NAME
114 return RegistryLocation(
115 registry_path=resolved_registry,
116 workspace_root=resolved_workspace,
117 git_common_dir=git_common_dir,
118 checkpoint_path=checkpoint_path,
119 )
120
121
122def _worker_registry_guard(env: Mapping[str, str], workspace_root: Path) -> None:
123 """Plan 142j R7: in worker context (``OC_AGENT_WORKER=1``), ``OC_AGENT_REGISTRY``
124 MUST be set, absolute, and resolve INSIDE this worktree's
125 ``.option_c/orchestration/`` boundary; else fail closed (``WorkerRegistryError``).
126
127 A not-yet-created ``registry.sqlite`` **inside** the boundary is VALID (the
128 per-worktree store is created on first use โ€” location, not existence, is
129 checked). Unset/empty OR resolving OUTSIDE the boundary (main repo / sibling
130 worktree / arbitrary temp) is fail-closed. Non-worker local CLI is unaffected
131 (the existing workspace/git fallback still applies).
132 """
133 if env.get(WORKER_ENV_VAR) != "1":
134 return
135 boundary = (Path(workspace_root) / DEFAULT_ORCHESTRATION_RELATIVE).resolve()
136 raw = env.get(REGISTRY_ENV_VAR)
137 if not raw:
139 f"worker context ({WORKER_ENV_VAR}=1) requires {REGISTRY_ENV_VAR} set to an "
140 f"absolute path inside this worktree's boundary {boundary} (got unset/empty)"
141 )
142 path = Path(raw).expanduser()
143 if not path.is_absolute():
145 f"worker {REGISTRY_ENV_VAR} must be an absolute path (got {raw!r})"
146 )
147 resolved = path.resolve()
148 try:
149 resolved.relative_to(boundary)
150 except ValueError as error:
152 f"worker {REGISTRY_ENV_VAR} {resolved} resolves OUTSIDE this worktree's "
153 f"boundary {boundary} โ€” refusing to read/write another worktree's store"
154 ) from error
155
156
158 anchor: Path,
159 env: Mapping[str, str],
160 workspace_root: Path,
161) -> Path:
162 env_value = env.get(REGISTRY_ENV_VAR)
163 if env_value:
164 path = Path(env_value).expanduser()
165 if not path.is_absolute():
166 raise ValueError(f"{REGISTRY_ENV_VAR} must be an absolute path")
167 return path.resolve()
168
169 manifest = _find_workspace_manifest(anchor)
170 if manifest is not None:
171 root = _workspace_root_from_manifest(manifest)
172 return (root / DEFAULT_REGISTRY_RELATIVE).resolve()
173
174 common_dir = _git_common_dir(anchor)
175 if common_dir is not None:
176 return (common_dir.parent / DEFAULT_REGISTRY_RELATIVE).resolve()
177
178 return (workspace_root / DEFAULT_REGISTRY_RELATIVE).resolve()
179
180
182 anchor: Path,
183 workspace_root: str | os.PathLike[str] | None,
184) -> Path:
185 if workspace_root is not None:
186 return _absolute_path(Path(workspace_root), anchor)
187 manifest = _find_workspace_manifest(anchor)
188 if manifest is not None:
189 return _workspace_root_from_manifest(manifest)
190 try:
191 return find_workspace_root(anchor).resolve()
192 except FileNotFoundError:
193 return anchor.resolve()
194
195
196def _workspace_root_from_manifest(manifest: Path) -> Path:
197 try:
198 data = json.loads(manifest.read_text(encoding="utf-8"))
199 except (OSError, json.JSONDecodeError):
200 return manifest.parent.resolve()
201 for key in ("workspace_root", "root", "workspaceRoot"):
202 raw_value = data.get(key) if isinstance(data, dict) else None
203 if isinstance(raw_value, str) and raw_value.strip():
204 return _absolute_path(Path(raw_value).expanduser(), manifest.parent)
205 return manifest.parent.resolve()
206
207
208def _find_workspace_manifest(anchor: Path) -> Path | None:
209 current = anchor if anchor.is_dir() else anchor.parent
210 for candidate in (current, *current.parents):
211 manifest = candidate / WORKSPACE_MANIFEST
212 if manifest.is_file():
213 return manifest.resolve()
214 return None
215
216
217def _git_common_dir(anchor: Path) -> Path | None:
218 try:
219 result = subprocess.run(
220 ["git", "rev-parse", "--git-common-dir"],
221 cwd=str(anchor),
222 check=False,
223 capture_output=True,
224 text=True,
225 )
226 except (OSError, ValueError):
227 return None
228 if result.returncode != 0:
229 return None
230 raw_value = result.stdout.strip()
231 if not raw_value:
232 return None
233 return _absolute_path(Path(raw_value), anchor)
234
235
236def _anchor_path(start: str | os.PathLike[str] | None) -> Path:
237 return Path.cwd().resolve() if start is None else Path(start).expanduser().resolve()
238
239
240def _absolute_path(path: Path, base: Path) -> Path:
241 if path.is_absolute():
242 return path.resolve()
243 return (base / path).resolve()
Path _anchor_path(str|os.PathLike[str]|None start)
Definition discovery.py:236
Path _workspace_root_from_manifest(Path manifest)
Definition discovery.py:196
Path _discover_path(Path anchor, Mapping[str, str] env, Path workspace_root)
Definition discovery.py:161
Path _resolve_workspace_root(Path anchor, str|os.PathLike[str]|None workspace_root)
Definition discovery.py:184
Path|None _find_workspace_manifest(Path anchor)
Definition discovery.py:208
Path|None _git_common_dir(Path anchor)
Definition discovery.py:217
Path discover_registry_path(str|os.PathLike[str]|None start=None, Mapping[str, str]|None env=None)
Definition discovery.py:88
RegistryLocation discover_registry_location(str|os.PathLike[str]|None start=None, Mapping[str, str]|None env=None, str|os.PathLike[str]|None registry_path=None, str|os.PathLike[str]|None workspace_root=None)
Definition discovery.py:99
Path _absolute_path(Path path, Path base)
Definition discovery.py:240
None _worker_registry_guard(Mapping[str, str] env, Path workspace_root)
Definition discovery.py:122