feat: add scoped identifier registrar
This commit is contained in:
parent
859df9aae7
commit
2bf569d3d5
6 changed files with 435 additions and 2 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -120,6 +120,16 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||
```
|
||||
Coding agents should run this directly; ask the operator only if the CLI or
|
||||
State Hub API is unavailable. This syncs task status from files into the hub DB.
|
||||
If C-06/C-11 says this host is not the identifier registrar, do not retry
|
||||
and do not set `STATEHUB_REGISTRAR` yourself. Use the governed, single-repo
|
||||
fallback once the branch is clean and synchronized:
|
||||
```bash
|
||||
uv run --project ~/repo-manager rmgr registrar-reconcile \
|
||||
--path . --confirm-primary --push
|
||||
```
|
||||
If that command is unavailable, send one registrar request to `repo-manager`
|
||||
naming the repository and missing canonical ids, then continue file-backed
|
||||
work without UUIDs.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,5 +214,7 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`.
|
|||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
|
||||
State Hub API is unavailable.
|
||||
2. Run `statehub fix-consistency` locally.
|
||||
3. On a non-registrar C-06/C-11 skip, invoke `rmgr registrar-reconcile` once as
|
||||
documented in the session-close protocol. Never set the registrar environment
|
||||
variable directly and never send repeated requests for the same ids.
|
||||
|
|
|
|||
|
|
@ -53,4 +53,20 @@ the registrar. Disconnected work cannot register until T03. Implementation:
|
|||
`repo_manager.registrar.is_identifier_registrar`; consumed by
|
||||
`statehub fix-consistency` C-06 / C-11 / C-32.
|
||||
|
||||
The production fleet sweep is disabled and is not the interactive recovery
|
||||
path. Repo Manager provides a bounded on-demand registrar for one clean,
|
||||
up-to-date repository at a time:
|
||||
|
||||
```bash
|
||||
uv run --project ~/repo-manager rmgr registrar-reconcile \
|
||||
--path /path/to/repo --confirm-primary --push
|
||||
```
|
||||
|
||||
The command verifies the authoritative State Hub health endpoint, refuses dirty
|
||||
or ahead/behind branches and retired Gitea origins, serializes local registrar
|
||||
runs, and grants `STATEHUB_REGISTRAR=1` only to its scoped child process. Agents
|
||||
must not export that variable themselves. If the command is unavailable, send
|
||||
one deduplicated request to `repo-manager`; repeated `fix-consistency` runs cannot
|
||||
resolve the gate and waste execution time.
|
||||
|
||||
Work: `RMGR-WP-0005-T01`.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from repo_manager.commands.rapp import add_rapp_parser
|
||||
|
|
@ -45,6 +46,24 @@ def main(argv: list[str] | None = None) -> int:
|
|||
p_rec.add_argument("--slug", default=None)
|
||||
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
|
||||
|
||||
p_registrar = sub.add_parser(
|
||||
"registrar-reconcile",
|
||||
help="Assign missing State Hub UUIDs through a scoped on-demand registrar",
|
||||
)
|
||||
p_registrar.add_argument("--path", default=".", help="Repository checkout path")
|
||||
p_registrar.add_argument(
|
||||
"--api-base",
|
||||
default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"),
|
||||
help="Authoritative State Hub API base",
|
||||
)
|
||||
p_registrar.add_argument("--statehub-bin", default=None, help="Override statehub executable")
|
||||
p_registrar.add_argument(
|
||||
"--confirm-primary",
|
||||
action="store_true",
|
||||
help="Confirm that --api-base is the authoritative hub",
|
||||
)
|
||||
p_registrar.add_argument("--push", action="store_true", help="Push the registrar commit")
|
||||
|
||||
p_cmd = sub.add_parser(
|
||||
"update-task-status",
|
||||
help="Command repo.work.update_task_status (file + git commit)",
|
||||
|
|
@ -236,6 +255,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
|
||||
return 0
|
||||
|
||||
if args.command == "registrar-reconcile":
|
||||
from repo_manager.commands.registrar_reconcile import registrar_reconcile
|
||||
|
||||
result = registrar_reconcile(
|
||||
Path(args.path),
|
||||
api_base=args.api_base,
|
||||
statehub_bin=args.statehub_bin,
|
||||
confirm_primary=args.confirm_primary,
|
||||
push=args.push,
|
||||
)
|
||||
print(json.dumps(result.to_dict(), indent=2))
|
||||
return 0 if result.status in {"applied", "noop"} else 1
|
||||
|
||||
if args.command == "update-task-status":
|
||||
from repo_manager.commands.task_status import update_task_status
|
||||
|
||||
|
|
|
|||
265
src/repo_manager/commands/registrar_reconcile.py
Normal file
265
src/repo_manager/commands/registrar_reconcile.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""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.workplan import parse_workplan_file
|
||||
|
||||
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] = []
|
||||
workplans_dir = repo / "workplans"
|
||||
if not workplans_dir.is_dir():
|
||||
return {"workplans": workplans, "tasks": tasks}
|
||||
|
||||
# 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
|
||||
# Closed records are frozen provenance under ADR-007. State Hub also
|
||||
# deliberately refuses to create missing task rows for them.
|
||||
if (parsed.status or "").strip().lower() in {"finished", "archived"}:
|
||||
continue
|
||||
if not parsed.state_hub_workstream_id:
|
||||
workplans.append(parsed.id)
|
||||
for task in parsed.tasks:
|
||||
if task.id and not task.state_hub_task_id:
|
||||
tasks.append(task.id)
|
||||
return {"workplans": workplans, "tasks": tasks}
|
||||
|
||||
|
||||
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 _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 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,
|
||||
) -> RegistrarResult:
|
||||
"""Register missing workplan/task UUIDs through one scoped child process."""
|
||||
cid = str(uuid.uuid4())
|
||||
repo = path.expanduser().resolve()
|
||||
before = _missing_identifiers(repo)
|
||||
evidence: dict[str, Any] = {
|
||||
"repo_path": str(repo),
|
||||
"repo_slug": repo.name,
|
||||
"api_base": api_base.rstrip("/"),
|
||||
"missing_before": before,
|
||||
}
|
||||
|
||||
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 before["workplans"] and not before["tasks"]:
|
||||
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,
|
||||
)
|
||||
|
||||
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("/"),
|
||||
]
|
||||
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
|
||||
if completed.returncode not in {0, 2} or after["workplans"] or after["tasks"]:
|
||||
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 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)
|
||||
98
tests/test_registrar_reconcile.py
Normal file
98
tests/test_registrar_reconcile.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_manager.commands import registrar_reconcile as rr
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _fixture(tmp_path: Path) -> Path:
|
||||
bare = tmp_path / "remote.git"
|
||||
repo = tmp_path / "demo"
|
||||
_git(tmp_path, "init", "--bare", str(bare))
|
||||
_git(tmp_path, "clone", str(bare), str(repo))
|
||||
_git(repo, "config", "user.name", "Test")
|
||||
_git(repo, "config", "user.email", "test@example.com")
|
||||
(repo / "workplans").mkdir()
|
||||
(repo / "workplans" / "DEMO-WP-0001.md").write_text(
|
||||
"""---
|
||||
id: DEMO-WP-0001
|
||||
type: workplan
|
||||
title: Demo
|
||||
status: active
|
||||
---
|
||||
|
||||
## Task
|
||||
|
||||
```task
|
||||
id: DEMO-WP-0001-T01
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "seed")
|
||||
_git(repo, "push", "-u", "origin", "HEAD")
|
||||
return repo
|
||||
|
||||
|
||||
def test_requires_explicit_primary_confirmation(tmp_path: Path) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
result = rr.registrar_reconcile(repo)
|
||||
assert result.status == "rejected"
|
||||
assert result.error and result.error["code"] == "confirmation_required"
|
||||
|
||||
|
||||
def test_closed_records_are_not_registrar_work(tmp_path: Path) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
workplan = repo / "workplans" / "DEMO-WP-0001.md"
|
||||
workplan.write_text(
|
||||
workplan.read_text(encoding="utf-8").replace("status: active", "status: finished"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert rr._missing_identifiers(repo) == {"workplans": [], "tasks": []}
|
||||
|
||||
|
||||
def test_rejects_dirty_or_unsynced_repository(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None))
|
||||
(repo / "note.txt").write_text("dirty", encoding="utf-8")
|
||||
dirty = rr.registrar_reconcile(repo, confirm_primary=True)
|
||||
assert dirty.status == "rejected"
|
||||
assert dirty.error and dirty.error["code"] == "git_precondition_failed"
|
||||
|
||||
|
||||
def test_scopes_registrar_env_and_commits_assigned_ids(tmp_path: Path, monkeypatch) -> None:
|
||||
repo = _fixture(tmp_path)
|
||||
monkeypatch.setattr(rr, "_check_primary", lambda _api: ({"status": "ok", "db": "connected"}, None))
|
||||
|
||||
def fake_run(command, *, env):
|
||||
assert command[1:3] == ["fix-consistency", "--path"]
|
||||
assert env["STATEHUB_REGISTRAR"] == "1"
|
||||
workplan = repo / "workplans" / "DEMO-WP-0001.md"
|
||||
text = workplan.read_text(encoding="utf-8")
|
||||
text = text.replace("status: active\n---", 'status: active\nstate_hub_workstream_id: "11111111-1111-4111-8111-111111111111"\n---')
|
||||
text = text.replace("priority: high\n```", 'priority: high\nstate_hub_task_id: "22222222-2222-4222-8222-222222222222"\n```')
|
||||
workplan.write_text(text, encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(rr, "_run_statehub", fake_run)
|
||||
|
||||
result = rr.registrar_reconcile(
|
||||
repo,
|
||||
statehub_bin="statehub",
|
||||
confirm_primary=True,
|
||||
)
|
||||
|
||||
assert result.status == "applied"
|
||||
assert result.evidence["missing_after"] == {"workplans": [], "tasks": []}
|
||||
subject = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s"], cwd=repo, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
assert subject == "chore(registrar): assign State Hub identifiers"
|
||||
|
|
@ -170,6 +170,16 @@ scale back up. Note the pod runs as root and will re-create root-owned files
|
|||
under `/home/tegwick`, undoing today's ownership fix — it needs a non-root
|
||||
`runAsUser` or its own service account.
|
||||
|
||||
**Interim recovery revised 2026-08-21:** do not restore the host-wide sweep just
|
||||
to drain UUID requests. Repo Manager now owns a scoped on-demand command,
|
||||
`rmgr registrar-reconcile`, which preflights a clean synchronized Forgejo
|
||||
checkout and the authoritative hub, serializes the run, and sets
|
||||
`STATEHUB_REGISTRAR=1` only for one repository's `fix-consistency` child.
|
||||
It commits the identifier writeback and pushes only when explicitly requested.
|
||||
This is the coding-agent recovery path until T03 lands; agents must neither set
|
||||
the environment variable directly nor retry C-06/C-11 or send duplicate
|
||||
messages. The production sweep remains disabled behind T12.
|
||||
|
||||
`gitea` cannot be decommissioned yet: the rollback remotes still point at it by
|
||||
design. Drop them once a sweep or two confirms forgejo is healthy.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue