repo-manager/src/repo_manager/commands/registrar_reconcile.py
tegwick 055c6971ab feat(identifiers): verify batch projections
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
2026-08-22 23:50:34 +02:00

638 lines
24 KiB
Python

"""Scoped on-demand identifier registrar for the ADR-007 interim.
The production fleet sweep is intentionally disabled. This command provides
one explicit, repository-scoped way to run the existing State Hub registration
adapter without teaching ordinary agent sessions to set ``STATEHUB_REGISTRAR``
themselves.
"""
from __future__ import annotations
import fcntl
import os
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
from repo_manager.gitops import GitError, commit_paths, push_ff
from repo_manager.parse.record import iter_record_files, parse_record_file
from repo_manager.parse.workplan import parse_workplan_file
from repo_manager.record_identity import scan_record_identities
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
@dataclass
class RegistrarResult:
status: str
evidence: dict[str, Any]
error: dict[str, Any] | None = None
correlation_id: str = ""
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
"command": "repo.identifiers.registrar_reconcile",
"status": self.status,
"correlation_id": self.correlation_id,
"evidence": self.evidence,
}
if self.error:
result["error"] = self.error
return result
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=False,
)
def _missing_identifiers(repo: Path) -> dict[str, list[str]]:
workplans: list[str] = []
tasks: list[str] = []
intakes: list[str] = []
decisions: list[str] = []
workplans_dir = repo / "workplans"
if workplans_dir.is_dir():
# Closed archives are frozen under ADR-007 and are not registration work.
for path in sorted(workplans_dir.glob("*.md")):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.frontmatter.get("type") != "workplan" or not parsed.id:
continue
if not parsed.state_hub_workstream_id:
workplans.append(parsed.id)
# C-06 creates a newly discovered workplan and its tasks as one
# registration unit, including closed file-backed provenance.
# Include those task gaps so post-verification cannot report a
# false success after creating only the parent.
for task in parsed.tasks:
if task.id and not task.state_hub_task_id:
tasks.append(task.id)
continue
# Once a closed workplan is linked, missing task identifiers are
# frozen historical artefacts. State Hub's C-11 fixer deliberately
# refuses to create them.
if (parsed.status or "").strip().lower() in {"finished", "archived"}:
continue
for task in parsed.tasks:
if task.id and not task.state_hub_task_id:
tasks.append(task.id)
for path in iter_record_files(repo):
for record in parse_record_file(path, repo_root=repo):
if record.uuid:
continue
target = intakes if record.kind == "intake" else decisions
target.append(record.id)
return {
"workplans": workplans,
"tasks": tasks,
"intakes": intakes,
"decisions": decisions,
}
def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
status = _git(repo, "status", "--porcelain")
if status.returncode != 0:
return {}, status.stderr.strip() or "not a Git repository"
if status.stdout.strip():
return {}, "worktree must be clean before registrar reconciliation"
upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if upstream.returncode != 0:
return {}, "current branch must have an upstream"
counts = _git(repo, "rev-list", "--left-right", "--count", "@{u}...HEAD")
if counts.returncode != 0:
return {}, counts.stderr.strip() or "could not compare HEAD with upstream"
try:
behind, ahead = (int(value) for value in counts.stdout.split())
except (TypeError, ValueError):
return {}, "could not parse Git ahead/behind state"
if behind or ahead:
return (
{"upstream": upstream.stdout.strip(), "behind": behind, "ahead": ahead},
"branch must match its upstream; pull or push before registrar reconciliation",
)
remote = _git(repo, "remote", "get-url", "origin")
if remote.returncode != 0:
return {}, "origin remote is required"
remote_url = remote.stdout.strip()
stale_markers = ("gitea-remote", "gitea.coulomb.social", "92.205.130.254")
if any(marker in remote_url for marker in stale_markers):
return {"origin": remote_url}, "origin still targets the retired Gitea lineage"
return {
"upstream": upstream.stdout.strip(),
"behind": behind,
"ahead": ahead,
"origin": remote_url,
}, None
def _check_primary(api_base: str) -> tuple[dict[str, Any], str | None]:
try:
response = httpx.get(f"{api_base.rstrip('/')}/state/health", timeout=10.0)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
return {}, f"primary State Hub health check failed: {exc}"
if payload.get("status") != "ok" or payload.get("db") != "connected":
return payload, "State Hub is not healthy and database-connected"
return payload, None
def _workplan_projection_id(repo: Path, canonical_id: str) -> str | None:
"""Return the authoritative projection UUID for one open workplan."""
workplans_dir = repo / "workplans"
if not workplans_dir.is_dir():
return None
for path in sorted(workplans_dir.glob("*.md")):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.id != canonical_id:
continue
return parsed.state_hub_workstream_id or None
return None
def _projection_exists(api_base: str, projection_id: str) -> tuple[dict[str, Any], str | None]:
try:
response = httpx.get(
f"{api_base.rstrip('/')}/workplans/{projection_id}",
timeout=10.0,
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
return {}, f"workplan projection verification failed: {exc}"
if str(payload.get("id")) != projection_id:
return payload, "workplan projection returned an unexpected identity"
return payload, None
def _check_empty_repo_projection(
api_base: str, repo_slug: str
) -> tuple[dict[str, Any], str | None]:
try:
repo_response = httpx.get(f"{api_base.rstrip('/')}/repos/{repo_slug}", timeout=10.0)
repo_response.raise_for_status()
repo_payload = repo_response.json()
response = httpx.get(
f"{api_base.rstrip('/')}/workplans/",
params={"repo_id": repo_payload["id"]},
timeout=10.0,
)
response.raise_for_status()
rows = response.json()
except (httpx.HTTPError, KeyError, ValueError) as exc:
return {}, f"empty projection preflight failed: {exc}"
if not isinstance(rows, list):
return {}, "empty projection preflight returned an invalid workplan collection"
evidence = {"repo_id": repo_payload["id"], "workplan_count": len(rows)}
if rows:
return evidence, "target repository projection is not empty"
return evidence, None
def _authoritative_projection_ids(
repo: Path,
) -> tuple[set[str], set[str], dict[str, set[str]], str | None]:
workplan_ids: set[str] = set()
task_ids: set[str] = set()
record_ids: dict[str, set[str]] = {"intake": set(), "decision": set()}
for path in sorted((repo / "workplans").glob("*.md")):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.frontmatter.get("type") != "workplan" or not parsed.id:
continue
if not parsed.state_hub_workstream_id:
return set(), set(), record_ids, f"{parsed.id} has no authoritative projection UUID"
workplan_ids.add(parsed.state_hub_workstream_id)
for task in parsed.tasks:
if task.id and not task.state_hub_task_id:
return set(), set(), record_ids, f"{task.id} has no authoritative projection UUID"
if task.state_hub_task_id:
task_ids.add(task.state_hub_task_id)
if not workplan_ids:
return set(), set(), record_ids, "repository has no root workplans to rebuild"
for path in iter_record_files(repo):
for record in parse_record_file(path, repo_root=repo):
if record.kind not in record_ids:
continue
if not record.uuid:
return (
set(),
set(),
record_ids,
f"{record.id} has no authoritative projection UUID",
)
record_ids[record.kind].add(record.uuid)
return workplan_ids, task_ids, record_ids, None
def _verify_full_projection(
api_base: str,
workplan_ids: set[str],
task_ids: set[str],
record_ids: dict[str, set[str]],
) -> tuple[dict[str, Any], str | None]:
missing_workplans: list[str] = []
missing_tasks: list[str] = []
missing_records: dict[str, list[str]] = {"intake": [], "decision": []}
try:
for projection_id in sorted(workplan_ids):
response = httpx.get(
f"{api_base.rstrip('/')}/workplans/{projection_id}", timeout=10.0
)
if response.status_code == 404:
missing_workplans.append(projection_id)
else:
response.raise_for_status()
for projection_id in sorted(task_ids):
response = httpx.get(f"{api_base.rstrip('/')}/tasks/{projection_id}", timeout=10.0)
if response.status_code == 404:
missing_tasks.append(projection_id)
else:
response.raise_for_status()
for kind, endpoint in (("intake", "intakes"), ("decision", "decisions")):
for projection_id in sorted(record_ids[kind]):
response = httpx.get(
f"{api_base.rstrip('/')}/{endpoint}/{projection_id}", timeout=10.0
)
if response.status_code == 404:
missing_records[kind].append(projection_id)
else:
response.raise_for_status()
except httpx.HTTPError as exc:
return {}, f"full projection verification failed: {exc}"
evidence = {
"expected_workplans": len(workplan_ids),
"expected_tasks": len(task_ids),
"missing_workplans": missing_workplans,
"missing_tasks": missing_tasks,
"expected_intakes": len(record_ids["intake"]),
"expected_decisions": len(record_ids["decision"]),
"missing_intakes": missing_records["intake"],
"missing_decisions": missing_records["decision"],
}
if missing_workplans or missing_tasks or any(missing_records.values()):
return evidence, "full projection rebuild is incomplete"
return evidence, None
def _requested_projection_ids(
repo: Path,
requested: dict[str, list[str]],
) -> tuple[set[str], set[str], dict[str, set[str]], str | None]:
"""Resolve only the identifiers requested by this registrar invocation."""
wanted_workplans = set(requested["workplans"])
wanted_tasks = set(requested["tasks"])
wanted_records = {
"intake": set(requested["intakes"]),
"decision": set(requested["decisions"]),
}
resolved_workplans: dict[str, str] = {}
resolved_tasks: dict[str, str] = {}
resolved_records: dict[str, dict[str, str]] = {"intake": {}, "decision": {}}
for path in sorted((repo / "workplans").glob("*.md")):
parsed = parse_workplan_file(path, repo_root=repo)
if parsed.id in wanted_workplans and parsed.state_hub_workstream_id:
resolved_workplans[parsed.id] = parsed.state_hub_workstream_id
for task in parsed.tasks:
if task.id in wanted_tasks and task.state_hub_task_id:
resolved_tasks[task.id] = task.state_hub_task_id
for path in iter_record_files(repo):
for record in parse_record_file(path, repo_root=repo):
if record.kind in wanted_records and record.id in wanted_records[record.kind] and record.uuid:
resolved_records[record.kind][record.id] = record.uuid
unresolved = sorted(
(wanted_workplans - resolved_workplans.keys())
| (wanted_tasks - resolved_tasks.keys())
| (wanted_records["intake"] - resolved_records["intake"].keys())
| (wanted_records["decision"] - resolved_records["decision"].keys())
)
record_ids = {
kind: set(records.values()) for kind, records in resolved_records.items()
}
if unresolved:
return (
set(resolved_workplans.values()),
set(resolved_tasks.values()),
record_ids,
f"requested records have no assigned projection UUID: {', '.join(unresolved)}",
)
return (
set(resolved_workplans.values()),
set(resolved_tasks.values()),
record_ids,
None,
)
def _run_statehub(command: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
capture_output=True,
text=True,
check=False,
timeout=300,
env=env,
)
def _restore_generated_brief(repo: Path, paths: list[str]) -> tuple[list[str], str | None]:
"""Restore the generated brief after a projection-only repair child."""
if not paths:
return [], None
if paths != [".custodian-brief.md"]:
return [], "projection repair changed files other than the generated Custodian brief"
original = _git(repo, "show", "HEAD:.custodian-brief.md")
if original.returncode != 0:
return [], original.stderr.strip() or "could not read the committed Custodian brief"
(repo / ".custodian-brief.md").write_text(original.stdout, encoding="utf-8")
remaining = _git(repo, "status", "--porcelain")
if remaining.returncode != 0 or remaining.stdout.strip():
return [], remaining.stderr.strip() or "generated brief restoration left a dirty worktree"
return paths, None
def registrar_reconcile(
path: Path,
*,
api_base: str = "http://127.0.0.1:8000",
statehub_bin: str | None = None,
confirm_primary: bool = False,
push: bool = False,
repair_workplan: str | None = None,
bootstrap_empty_projection: bool = False,
) -> RegistrarResult:
"""Register missing workplan/task UUIDs through one scoped child process."""
cid = str(uuid.uuid4())
repo = path.expanduser().resolve()
identity = scan_record_identities(repo)
before = _missing_identifiers(repo)
evidence: dict[str, Any] = {
"repo_path": str(repo),
"repo_slug": repo.name,
"api_base": api_base.rstrip("/"),
"missing_before": before,
"record_identity": identity,
}
if identity["identity_collisions"]:
return RegistrarResult(
"rejected",
evidence,
{
"code": "record_identity_collision",
"message": "same canonical work-record id has conflicting or incomplete UUID assignments",
},
cid,
)
repair_projection_id = None
if repair_workplan and bootstrap_empty_projection:
return RegistrarResult(
"rejected",
evidence,
{
"code": "registrar_mode_conflict",
"message": "repair-workplan and bootstrap-empty-projection are mutually exclusive",
},
cid,
)
if repair_workplan:
repair_projection_id = _workplan_projection_id(repo, repair_workplan)
evidence["repair_workplan"] = repair_workplan
evidence["repair_projection_id"] = repair_projection_id
if not repair_projection_id:
return RegistrarResult(
"rejected",
evidence,
{
"code": "repair_target_invalid",
"message": "repair workplan is absent or has no authoritative projection UUID",
},
cid,
)
if not confirm_primary:
return RegistrarResult(
"rejected",
evidence,
{
"code": "confirmation_required",
"message": "pass --confirm-primary after verifying this is the authoritative hub",
},
cid,
)
if not any(before.values()) and not repair_projection_id and not bootstrap_empty_projection:
evidence["missing_after"] = before
return RegistrarResult("noop", evidence, None, cid)
git_evidence, git_error = _check_git(repo)
evidence["git"] = git_evidence
if git_error:
return RegistrarResult(
"rejected", evidence, {"code": "git_precondition_failed", "message": git_error}, cid
)
health, health_error = _check_primary(api_base)
evidence["state_hub_health"] = health
if health_error:
return RegistrarResult(
"rejected", evidence, {"code": "primary_unavailable", "message": health_error}, cid
)
executable = statehub_bin or shutil.which("statehub")
if not executable:
return RegistrarResult(
"rejected",
evidence,
{"code": "statehub_missing", "message": "statehub CLI is not installed"},
cid,
)
bootstrap_workplans: set[str] = set()
bootstrap_tasks: set[str] = set()
bootstrap_records: dict[str, set[str]] = {"intake": set(), "decision": set()}
if bootstrap_empty_projection:
bootstrap_before, bootstrap_error = _check_empty_repo_projection(api_base, repo.name)
evidence["bootstrap_projection_before"] = bootstrap_before
if bootstrap_error:
return RegistrarResult(
"rejected",
evidence,
{"code": "bootstrap_precondition_failed", "message": bootstrap_error},
cid,
)
(
bootstrap_workplans,
bootstrap_tasks,
bootstrap_records,
source_error,
) = _authoritative_projection_ids(repo)
evidence["bootstrap_source"] = {
"workplans": len(bootstrap_workplans),
"tasks": len(bootstrap_tasks),
"intakes": len(bootstrap_records["intake"]),
"decisions": len(bootstrap_records["decision"]),
}
if source_error:
return RegistrarResult(
"rejected",
evidence,
{"code": "bootstrap_source_invalid", "message": source_error},
cid,
)
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOCK_PATH.open("a+", encoding="utf-8") as lock:
try:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return RegistrarResult(
"rejected",
evidence,
{"code": "registrar_busy", "message": "another registrar reconciliation is active"},
cid,
)
child_env = {**os.environ, "STATEHUB_REGISTRAR": "1"}
command = [
executable,
"fix-consistency",
"--path",
str(repo),
"--api-base",
api_base.rstrip("/"),
]
if bootstrap_empty_projection:
command.append("--bootstrap-empty-projection")
completed = _run_statehub(command, env=child_env)
evidence["statehub_exit_code"] = completed.returncode
evidence["statehub_stdout_tail"] = completed.stdout[-4000:]
evidence["statehub_stderr_tail"] = completed.stderr[-2000:]
after = _missing_identifiers(repo)
evidence["missing_after"] = after
repair_verified = False
if repair_projection_id:
projection, projection_error = _projection_exists(api_base, repair_projection_id)
evidence["repair_projection"] = projection
evidence["repair_projection_verified"] = projection_error is None
if projection_error:
evidence["repair_projection_error"] = projection_error
repair_verified = projection_error is None
bootstrap_verified = False
if bootstrap_empty_projection:
projection, projection_error = _verify_full_projection(
api_base,
bootstrap_workplans,
bootstrap_tasks,
bootstrap_records,
)
evidence["bootstrap_projection"] = projection
evidence["bootstrap_projection_verified"] = projection_error is None
if projection_error:
evidence["bootstrap_projection_error"] = projection_error
bootstrap_verified = projection_error is None
requested_verified = False
if completed.returncode == 1 and any(before.values()) and not any(after.values()):
(
requested_workplans,
requested_tasks,
requested_records,
requested_source_error,
) = _requested_projection_ids(repo, before)
if requested_source_error:
evidence["requested_projection_error"] = requested_source_error
else:
projection, projection_error = _verify_full_projection(
api_base,
requested_workplans,
requested_tasks,
requested_records,
)
evidence["requested_projection"] = projection
evidence["requested_projection_verified"] = projection_error is None
if projection_error:
evidence["requested_projection_error"] = projection_error
requested_verified = projection_error is None
accepted_exit_codes = {0, 2}
if repair_verified or bootstrap_verified or requested_verified:
# A repository-scoped projection repair may coexist with legacy stale
# references that correctly keep the broader consistency report red.
accepted_exit_codes.add(1)
mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
bootstrap_empty_projection and not bootstrap_verified
)
if (
completed.returncode not in accepted_exit_codes
or any(after.values())
or mode_verification_failed
):
return RegistrarResult(
"failed",
evidence,
{
"code": "registration_incomplete",
"message": "State Hub reconciliation did not assign every requested identifier",
},
cid,
)
changed = _git(repo, "status", "--porcelain")
paths = [line[3:] for line in changed.stdout.splitlines() if len(line) > 3]
if (repair_verified or bootstrap_verified) and paths:
restored, restore_error = _restore_generated_brief(repo, paths)
evidence["restored_generated_paths"] = restored
if restore_error:
return RegistrarResult(
"failed",
evidence,
{"code": "projection_repair_file_change", "message": restore_error},
cid,
)
paths = []
if paths:
try:
evidence["git_sha"] = commit_paths(
repo,
paths,
"chore(registrar): assign State Hub identifiers",
)
except GitError as exc:
return RegistrarResult(
"failed",
evidence,
{"code": "commit_failed", "message": str(exc)},
cid,
)
if push:
pushed, message = push_ff(repo)
evidence["push"] = {"ok": pushed, "message": message}
if not pushed:
return RegistrarResult(
"failed",
evidence,
{"code": "push_failed", "message": message},
cid,
)
return RegistrarResult("applied", evidence, None, cid)