3"""Read-only client for canonical ``agent_workspace.sh`` ownership metadata."""
5from __future__
import annotations
11from dataclasses
import dataclass
12from pathlib
import Path
13from typing
import NoReturn
15from work_git
import WorkError, discover_repo, resolve_commit, run_git_readonly
19KEY_RE = re.compile(
r"^[a-z0-9][a-z0-9-]{0,62}$")
20ISSUE_RE = re.compile(
r"^[1-9][0-9]{0,9}$")
21COMMIT_RE = re.compile(
r"^[0-9a-f]{40,64}$")
40class ClaimError(WorkError):
41 """Canonical workspace metadata is unreadable or contradictory."""
44def fail(message: str) -> NoReturn:
45 """Raise one metadata error."""
46 raise ClaimError(message)
49def is_identifier(value: str) -> bool:
50 """Return whether ``value`` is an issue number or safe plan key."""
51 return ISSUE_RE.fullmatch(value)
is not None or KEY_RE.fullmatch(value)
is not None
54def workspace_name(identifier: str) -> str:
55 """Return the canonical workspace name for one work identifier."""
56 if not is_identifier(identifier):
57 fail(
"invalid work identifier")
58 return f
"work-{identifier}"
61def branch_name(identifier: str) -> str:
62 """Return the canonical branch for one work identifier."""
63 if not is_identifier(identifier):
64 fail(
"invalid work identifier")
65 return f
"work/{identifier}"
68def metadata_dir(ws_root: Path) -> Path:
69 """Return the canonical workspace metadata directory."""
70 return ws_root /
".meta"
73def metadata_path(ws_root: Path, identifier: str) -> Path:
74 """Return one canonical metadata path."""
75 return metadata_dir(ws_root) / workspace_name(identifier)
78def lexical_absolute(path: Path) -> Path:
79 """Return an absolute normalized path without following any symlink.
82 path: Path supplied by configuration or metadata.
85 An absolute lexical path. ``..`` components are normalized, while a
86 symlinked child remains visible to the later ``lstat`` check.
90 os.fspath(path.expanduser())
95@dataclass(frozen=True)
97 """One canonical workspace record owned by the work client."""
109def _single_line(value: str, field: str) -> str:
110 """Require safe single-line metadata text."""
111 if not value
or not value.isascii()
or not value.isprintable():
112 fail(f
"metadata field {field} must be non-empty printable single-line ASCII")
116def _read_fields(path: Path) -> dict[str, str]:
117 """Read one private, regular canonical metadata record."""
120 except FileNotFoundError:
121 fail(f
"canonical metadata is absent: {path}")
122 if stat.S_ISLNK(info.st_mode)
or not stat.S_ISREG(info.st_mode):
123 fail(f
"canonical metadata is not a regular file: {path}")
125 lines = path.read_text(encoding=
"ascii").splitlines()
126 except (OSError, UnicodeError):
127 fail(f
"canonical metadata could not be read: {path}")
128 fields: dict[str, str] = {}
130 key, separator, value = line.partition(
"=")
131 if not separator
or key
in fields:
132 fail(f
"canonical metadata has an invalid or duplicate field: {path}")
133 fields[key] = _single_line(value, key)
134 if set(fields) != EXPECTED_FIELDS
or fields.get(
"schema") != SCHEMA:
135 fail(f
"canonical metadata schema is not supported: {path}")
139def load_claim(path: Path, ws_root: Path) -> Claim:
140 """Load and internally validate one work-owned canonical record."""
141 fields = _read_fields(path)
142 name = fields[
"name"]
143 if fields[
"owner"] != OWNER
or path.name != name
or not name.startswith(
"work-"):
144 fail(f
"metadata is not a canonical work-owned claim: {path}")
145 identifier = name.removeprefix(
"work-")
146 root = lexical_absolute(ws_root)
147 expected_path = root / name
148 recorded_path = lexical_absolute(Path(fields[
"path"]))
150 is_identifier(identifier),
151 fields[
"branch"] == branch_name(identifier),
152 recorded_path.is_absolute(),
153 recorded_path == expected_path,
154 COMMIT_RE.fullmatch(fields[
"base_commit"])
is not None,
157 fail(f
"canonical work claim contradicts its derived identity: {path}")
159 worktree_info = expected_path.lstat()
160 except FileNotFoundError:
162 if worktree_info
is not None and (
163 stat.S_ISLNK(worktree_info.st_mode)
or not stat.S_ISDIR(worktree_info.st_mode)
165 fail(f
"canonical worktree path is not a real directory: {expected_path}")
167 identifier=identifier,
169 worktree=recorded_path,
170 branch=fields[
"branch"],
171 base_ref=fields[
"ref"],
172 base_commit=fields[
"base_commit"],
173 created=fields[
"created"],
174 creator=fields[
"by"],
178def list_claims(ws_root: Path) -> list[tuple[Path, Claim | ClaimError]]:
179 """List canonical work claims without interpreting agent-owned metadata."""
180 directory = metadata_dir(ws_root)
181 if not directory.exists():
183 if not directory.is_dir()
or directory.is_symlink():
184 fail(f
"canonical metadata directory is unsafe: {directory}")
185 found: list[tuple[Path, Claim | ClaimError]] = []
186 for path
in sorted(directory.iterdir(), key=
lambda item: item.name):
188 if path.name.startswith(
"work-"):
189 found.append((path, load_claim(path, ws_root)))
191 fields = _read_fields(path)
192 if fields.get(
"owner") == OWNER:
193 found.append((path, ClaimError(f
"work owner used a nonreserved name: {path}")))
194 except ClaimError
as exc:
197 if path.name.startswith(
"work-"):
198 found.append((path, exc))
202def _worktree_bindings(cwd: Path) -> dict[Path, tuple[str |
None, str |
None]]:
203 """Return registered path -> (branch, HEAD) bindings from Git porcelain."""
204 done = run_git_readonly([
"worktree",
"list",
"--porcelain"], cwd=cwd)
206 fail(
"git worktree inventory failed")
207 result: dict[Path, tuple[str |
None, str |
None]] = {}
208 path: Path |
None =
None
209 head: str |
None =
None
210 branch: str |
None =
None
211 for line
in [*done.stdout.splitlines(),
""]:
214 result[lexical_absolute(path)] = (branch, head)
215 path, head, branch =
None,
None,
None
216 elif line.startswith(
"worktree "):
217 path = Path(line.removeprefix(
"worktree "))
218 elif line.startswith(
"HEAD "):
219 head = line.removeprefix(
"HEAD ")
220 elif line.startswith(
"branch refs/heads/"):
221 branch = line.removeprefix(
"branch refs/heads/")
225def classify(claim: Claim, cwd: Path) -> str:
226 """Verify that the registered worktree is attached to the claimed branch."""
228 info = claim.worktree.lstat()
229 except FileNotFoundError:
231 if stat.S_ISLNK(info.st_mode)
or not stat.S_ISDIR(info.st_mode):
233 binding = _worktree_bindings(cwd).get(lexical_absolute(claim.worktree))
236 branch, registered_head = binding
237 if branch != claim.branch
or registered_head
is None:
239 return _classify_repository(claim, cwd, registered_head)
242def _classify_repository(claim: Claim, cwd: Path, registered_head: str) -> str:
243 """Verify repository identity and immutable object bindings for one claim."""
245 caller_repo = discover_repo(cwd)
246 claim_repo = discover_repo(claim.worktree)
249 if claim_repo.common_dir != caller_repo.common_dir:
251 head = resolve_commit(
"HEAD", cwd=claim.worktree)
252 base = resolve_commit(claim.base_commit, cwd=claim.worktree)
253 if head != registered_head
or base != claim.base_commit:
258def recovery_command(claim: Claim, repo_root: Path, *, stale: bool) -> str:
259 """Return one shell-quoted canonical recovery command for human review."""
260 action =
"forget" if stale
else "release"
262 [
"/bin/bash",
"-p", str(repo_root /
"scripts/dev/agent_workspace.sh"), action, claim.name]