From 7ea7690dfce1a29c321b31a4e85bca8793c8e034 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 20:48:21 +0200 Subject: [PATCH] fix registrar writebacks and own the State Hub access map Ignore generated WORK-RECORDS.md/.custodian-brief.md in the registrar git precondition, commit identifier writebacks even when registration is incomplete, and name the remaining records in the error. Add config/state-hub-access.yaml as the single source for the AGENTS.md port map, rendered by rmgr scaffold and refreshed with --refresh-hub-access. Assistant: grok Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436 --- AGENTS.md | 6 +- README.md | 1 + config/state-hub-access.yaml | 19 +++ src/repo_manager/cli.py | 37 +++-- .../commands/registrar_reconcile.py | 128 +++++++++++++----- src/repo_manager/commands/scaffold.py | 38 +++++- src/repo_manager/hub_access.py | 111 +++++++++++++++ tests/test_hub_access.py | 87 ++++++++++++ tests/test_registrar_reconcile.py | 95 +++++++++++++ tests/test_scaffold.py | 2 + 10 files changed, 480 insertions(+), 44 deletions(-) create mode 100644 config/state-hub-access.yaml create mode 100644 src/repo_manager/hub_access.py create mode 100644 tests/test_hub_access.py diff --git a/AGENTS.md b/AGENTS.md index eea66aa..e4ef0da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,11 +20,15 @@ the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridg adds severe call latency; the full administrative MCP surface remains available to clients that need it. + | Context | URL | |---------|-----| | Local workstation | `http://127.0.0.1:8000` | | Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` | -| Optional local edge relay | http://127.0.0.1:18080 | +| Optional local edge relay | `http://127.0.0.1:18080` | + +Do not instruct a remote agent to use `http://127.0.0.1:18000`. That reverse tunnel is retired; on railiance01 reach the hub at the in-cluster address above. + When an operator has enabled the edge relay, set API_BASE to the relay URL. Queueable writes return an explicit queued receipt if the central hub is diff --git a/README.md b/README.md index f3b7bb7..dd0906e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ rmgr rapp place --path ../rapp-some-app --reef reef-railiance rmgr conform --path . rmgr prefix-uniqueness --root .. rmgr scaffold --path ../prj-example --flavor project --wp-prefix EX-WP --no-commit +rmgr scaffold --path . --refresh-hub-access --no-commit # State Hub access table from config/state-hub-access.yaml ``` Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md). diff --git a/config/state-hub-access.yaml b/config/state-hub-access.yaml new file mode 100644 index 0000000..0e80a17 --- /dev/null +++ b/config/state-hub-access.yaml @@ -0,0 +1,19 @@ +schema: repo-manager.state-hub-access.v1 +updated: "2026-08-28" +workplan_task: CUST-WP-0067-T07 +notes: > + Authoritative State Hub access map for agent-facing AGENTS.md tables. + Edit this file, then `rmgr scaffold --path --refresh-hub-access`. + Local 127.0.0.1:8000 is central via ops-bridge state-hub-primary, not a cache. + +endpoints: + - context: Local workstation + url: http://127.0.0.1:8000 + - context: Remote (railiance01, in-cluster) + url: http://10.43.68.154:8000 + - context: Optional local edge relay + url: http://127.0.0.1:18080 + +retired: + - url: http://127.0.0.1:18000 + reason: reverse tunnel back to the workstation; the hub is in-cluster on railiance01 diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index 6ea6ff4..c9ed27a 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -295,12 +295,21 @@ def main(argv: list[str] | None = None) -> int: p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files") p_scaf.add_argument("--path", required=True) - p_scaf.add_argument("--flavor", required=True, choices=["experimental", "research", "project", "tooling", "product", "business"]) + p_scaf.add_argument( + "--flavor", + default=None, + choices=["experimental", "research", "project", "tooling", "product", "business"], + ) p_scaf.add_argument("--slug", default=None) p_scaf.add_argument("--domain", default="infotech") p_scaf.add_argument("--wp-prefix", default=None) p_scaf.add_argument("--force", action="store_true") p_scaf.add_argument("--no-commit", action="store_true") + p_scaf.add_argument( + "--refresh-hub-access", + action="store_true", + help="Replace the State Hub access table in AGENTS.md from config/state-hub-access.yaml", + ) p_provenance = sub.add_parser( "assistant-provenance", @@ -742,17 +751,23 @@ def main(argv: list[str] | None = None) -> int: return 0 if report.get("ok") else 1 if args.command == "scaffold": - from repo_manager.commands.scaffold import scaffold_repository + from repo_manager.commands.scaffold import refresh_hub_access, scaffold_repository - result = scaffold_repository( - Path(args.path), - flavor=args.flavor, - slug=args.slug, - domain=args.domain, - wp_prefix=args.wp_prefix, - force=args.force, - commit=not args.no_commit, - ) + if args.refresh_hub_access and not args.flavor: + result = refresh_hub_access(Path(args.path), commit=not args.no_commit) + else: + if not args.flavor: + print("scaffold: --flavor is required unless --refresh-hub-access is set", file=sys.stderr) + return 2 + result = scaffold_repository( + Path(args.path), + flavor=args.flavor, + slug=args.slug, + domain=args.domain, + wp_prefix=args.wp_prefix, + force=args.force, + commit=not args.no_commit, + ) print(json.dumps(result.to_dict(), indent=2)) return 0 if result.status == "applied" else 1 diff --git a/src/repo_manager/commands/registrar_reconcile.py b/src/repo_manager/commands/registrar_reconcile.py index 2108322..1249d66 100644 --- a/src/repo_manager/commands/registrar_reconcile.py +++ b/src/repo_manager/commands/registrar_reconcile.py @@ -26,6 +26,7 @@ from repo_manager.record_identity import scan_record_identities LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock") STATEHUB_TIMEOUT_SECONDS = 900 +GENERATED_INDEX_PATHS = frozenset({".custodian-brief.md", "WORK-RECORDS.md"}) @dataclass @@ -175,12 +176,52 @@ def _requested_identity_findings( ) +def _porcelain_paths(stdout: str) -> list[str]: + paths: list[str] = [] + for line in stdout.splitlines(): + if not line.strip(): + continue + if " -> " in line: + paths.append(line.split(" -> ", 1)[1].strip()) + continue + paths.append(line[3:] if len(line) > 3 else line.strip()) + return paths + + +def _remaining_record_ids(missing: dict[str, list[str]]) -> list[str]: + remaining: list[str] = [] + for key in ("workplans", "tasks", "intakes", "decisions"): + remaining.extend(missing.get(key) or []) + return remaining + + +def _registration_incomplete_message(after: dict[str, list[str]], stdout: str) -> str: + remaining = _remaining_record_ids(after) + named = ", ".join(remaining) if remaining else "see missing_after" + message = ( + "State Hub reconciliation did not assign every requested identifier: " + f"{named}" + ) + for line in stdout.splitlines(): + stripped = line.strip() + if "not created:" in stripped or "Internal Server Error" in stripped: + return f"{message}. Child: {stripped}" + return message + + 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" + dirty = _porcelain_paths(status.stdout) + generated = [path for path in dirty if path in GENERATED_INDEX_PATHS] + blocking = [path for path in dirty if path not in GENERATED_INDEX_PATHS] + if blocking: + return ( + {"dirty": dirty, "ignored_generated": generated}, + "worktree must be clean before registrar reconciliation; " + f"blocking: {', '.join(blocking)}", + ) upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") if upstream.returncode != 0: @@ -210,6 +251,7 @@ def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]: "behind": behind, "ahead": ahead, "origin": remote_url, + "ignored_generated": generated, }, None @@ -511,19 +553,34 @@ def _run_statehub(command: list[str], *, env: dict[str, str]) -> subprocess.Comp def _restore_generated_brief(repo: Path, paths: list[str]) -> tuple[list[str], str | None]: - """Restore the generated brief after a projection-only repair child.""" + """Restore generated indexes 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") + generated = [path for path in paths if path in GENERATED_INDEX_PATHS] + other = [path for path in paths if path not in GENERATED_INDEX_PATHS] + if other: + return [], ( + "projection repair changed files other than generated indexes: " + + ", ".join(other) + ) + restored: list[str] = [] + for path in generated: + original = _git(repo, "show", f"HEAD:{path}") + target = repo / path + if original.returncode != 0: + target.unlink(missing_ok=True) + else: + target.write_text(original.stdout, encoding="utf-8") + restored.append(path) 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 + leftover = [ + path + for path in _porcelain_paths(remaining.stdout) + if path not in GENERATED_INDEX_PATHS + ] + if remaining.returncode != 0 or leftover: + return restored, remaining.stderr.strip() or "generated index restoration left a dirty worktree" + return restored, None def registrar_reconcile( @@ -682,12 +739,14 @@ def registrar_reconcile( "decisions": len(bootstrap_records["decision"]), } if source_error: - return RegistrarResult( - "rejected", - evidence, - {"code": "bootstrap_source_invalid", "message": source_error}, - cid, - ) + error: dict[str, Any] = { + "code": "bootstrap_source_invalid", + "message": source_error, + } + token = source_error.split()[0] + if token not in {"repository", "a", "an", "the"}: + error["record"] = token + return RegistrarResult("rejected", evidence, error, cid) LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) with LOCK_PATH.open("a+", encoding="utf-8") as lock: @@ -792,23 +851,14 @@ def registrar_reconcile( mode_verification_failed = bool(repair_projection_id and not repair_verified) or ( bootstrap_empty_projection and not bootstrap_verified ) - if ( + incomplete = ( 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] + paths = _porcelain_paths(changed.stdout) if (repair_verified or bootstrap_verified) and paths: restored, restore_error = _restore_generated_brief(repo, paths) evidence["restored_generated_paths"] = restored @@ -827,6 +877,7 @@ def registrar_reconcile( paths, "chore(registrar): assign State Hub identifiers", ) + evidence["committed_paths"] = paths except GitError as exc: return RegistrarResult( "failed", @@ -838,7 +889,7 @@ def registrar_reconcile( if push: pushed, message = push_ff(repo) evidence["push"] = {"ok": pushed, "message": message} - if not pushed: + if not pushed and not incomplete: return RegistrarResult( "failed", evidence, @@ -848,4 +899,19 @@ def registrar_reconcile( evidence["workplan_bindings"] = _sync_workplan_bindings(repo, api_base, repo.name) + if incomplete: + remaining = _remaining_record_ids(after) + return RegistrarResult( + "failed", + evidence, + { + "code": "registration_incomplete", + "message": _registration_incomplete_message( + after, completed.stdout or "" + ), + "records": remaining, + }, + cid, + ) + return RegistrarResult("applied", evidence, None, cid) diff --git a/src/repo_manager/commands/scaffold.py b/src/repo_manager/commands/scaffold.py index 218467e..3ef8037 100644 --- a/src/repo_manager/commands/scaffold.py +++ b/src/repo_manager/commands/scaffold.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from repo_manager.gitops import GitError, commit_paths, is_git_repo +from repo_manager.hub_access import apply_hub_access_block, render_hub_access_block from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix from repo_manager.time import utc_today @@ -121,7 +122,9 @@ def scaffold_repository( "AGENTS.md", f"# Agent instructions — {slug}\n\n" f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\n" - f"Workplan prefix: `{prefix}-`.\n", + f"Workplan prefix: `{prefix}-`.\n\n" + "## State Hub Integration\n\n" + f"{render_hub_access_block()}\n", ) if prj: today = utc_today().isoformat() @@ -190,3 +193,36 @@ def scaffold_repository( if not written: evidence["noop"] = True return CommandResult("applied", evidence, None, cid) + + +def refresh_hub_access(path: Path, *, commit: bool = True) -> CommandResult: + """Replace the State Hub access table in AGENTS.md from the canonical map.""" + cid = str(uuid.uuid4()) + dest = path.expanduser().resolve() + agents = dest / "AGENTS.md" + evidence: dict[str, Any] = {"path": str(dest), "file": "AGENTS.md"} + if not agents.is_file(): + return CommandResult( + "rejected", + evidence, + {"message": "AGENTS.md is missing; scaffold the repository first"}, + cid, + ) + original = agents.read_text(encoding="utf-8") + updated, action = apply_hub_access_block(original) + evidence["action"] = action + if action == "noop": + evidence["noop"] = True + return CommandResult("applied", evidence, None, cid) + agents.write_text(updated, encoding="utf-8") + evidence["written"] = ["AGENTS.md"] + if commit and is_git_repo(dest): + try: + evidence["git_sha"] = commit_paths( + dest, + ["AGENTS.md"], + "docs(agents): refresh State Hub access map from repo-manager", + ) + except GitError as exc: + return CommandResult("failed", evidence, {"message": str(exc)}, cid) + return CommandResult("applied", evidence, None, cid) diff --git a/src/repo_manager/hub_access.py b/src/repo_manager/hub_access.py new file mode 100644 index 0000000..307ebf9 --- /dev/null +++ b/src/repo_manager/hub_access.py @@ -0,0 +1,111 @@ +"""Canonical State Hub access map for agent-facing AGENTS.md (CUST-WP-0067-T07). + +The port map used to be prose copied into ~180 AGENTS.md files. Topology +changes then required a fleet-wide rewrite. This module is the single source: +edit ``config/state-hub-access.yaml`` and refresh with +``rmgr scaffold --refresh-hub-access``. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +BEGIN = "" +END = "" +DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "state-hub-access.yaml" + +_FALLBACK: dict[str, Any] = { + "endpoints": [ + {"context": "Local workstation", "url": "http://127.0.0.1:8000"}, + {"context": "Remote (railiance01, in-cluster)", "url": "http://10.43.68.154:8000"}, + {"context": "Optional local edge relay", "url": "http://127.0.0.1:18080"}, + ], + "retired": [ + { + "url": "http://127.0.0.1:18000", + "reason": "reverse tunnel back to the workstation; the hub is in-cluster on railiance01", + } + ], +} + +_TABLE_RE = re.compile( + r"\| Context \| URL \|\r?\n\|[-:| ]+\|\r?\n(?:\|[^\n]*\|\r?\n)+", + re.MULTILINE, +) +_MARKED_RE = re.compile( + re.escape(BEGIN) + r".*?" + re.escape(END), + re.DOTALL, +) + + +def load_access(path: Path | None = None) -> dict[str, Any]: + target = path or DEFAULT_CONTRACT + if target.is_file(): + payload = yaml.safe_load(target.read_text(encoding="utf-8")) or {} + if isinstance(payload, dict) and payload.get("endpoints"): + return payload + return _FALLBACK + + +def render_hub_access_block(access: dict[str, Any] | None = None) -> str: + data = access or load_access() + rows = ["| Context | URL |", "|---------|-----|"] + for item in data.get("endpoints") or []: + context = str(item.get("context") or "").strip() + url = str(item.get("url") or "").strip() + rows.append(f"| {context} | `{url}` |") + retired = data.get("retired") or [] + note_lines: list[str] = [] + for item in retired: + url = str(item.get("url") or "").strip() + if not url: + continue + note_lines.append( + f"Do not instruct a remote agent to use `{url}`. That reverse tunnel " + "is retired; on railiance01 reach the hub at the in-cluster address above." + ) + body = "\n".join(rows) + if note_lines: + body = body + "\n\n" + "\n".join(note_lines) + return f"{BEGIN}\n{body}\n{END}" + + +def _table_is_hub_access(table: str) -> bool: + lowered = table.lower() + return "local workstation" in lowered and ( + "railiance01" in lowered or "10.43.68.154" in lowered or "127.0.0.1:18000" in lowered + ) + + +def apply_hub_access_block(text: str, block: str | None = None) -> tuple[str, str]: + """Insert or replace the canonical access table. Returns (new_text, action).""" + block = block or render_hub_access_block() + if block in text: + return text, "noop" + marked = _MARKED_RE.search(text) + if marked: + return text[: marked.start()] + block + text[marked.end() :], "replaced-marked" + for match in _TABLE_RE.finditer(text): + if _table_is_hub_access(match.group(0)): + return text[: match.start()] + block + "\n" + text[match.end() :], "replaced-table" + heading = re.search(r"^## State Hub Integration\s*$", text, re.MULTILINE) + if heading: + insert_at = heading.end() + return text[:insert_at] + "\n\n" + block + text[insert_at:], "inserted" + if text and not text.endswith("\n"): + text += "\n" + return text + "\n## State Hub Integration\n\n" + block + "\n", "appended" + + +def live_urls(access: dict[str, Any] | None = None) -> list[str]: + data = access or load_access() + return [str(item.get("url") or "").strip() for item in data.get("endpoints") or []] + + +def retired_urls(access: dict[str, Any] | None = None) -> list[str]: + data = access or load_access() + return [str(item.get("url") or "").strip() for item in data.get("retired") or []] diff --git a/tests/test_hub_access.py b/tests/test_hub_access.py new file mode 100644 index 0000000..19a0a46 --- /dev/null +++ b/tests/test_hub_access.py @@ -0,0 +1,87 @@ +from pathlib import Path + +from repo_manager.cli import main +from repo_manager.commands.scaffold import refresh_hub_access, scaffold_repository +from repo_manager.hub_access import ( + apply_hub_access_block, + live_urls, + render_hub_access_block, + retired_urls, +) + + +def test_canonical_map_matches_live_topology() -> None: + urls = live_urls() + assert "http://127.0.0.1:8000" in urls + assert "http://10.43.68.154:8000" in urls + assert "http://127.0.0.1:18000" not in urls + assert "http://127.0.0.1:18000" in retired_urls() + + +def test_rendered_table_does_not_instruct_retired_tunnel() -> None: + block = render_hub_access_block() + table, _, note = block.partition("\n\n") + assert "`http://10.43.68.154:8000`" in table + assert "`http://127.0.0.1:18000`" not in table + assert "127.0.0.1:18000" in note + + +def test_replace_unmarked_table_and_idempotent_refresh() -> None: + original = ( + "# Agent instructions\n\n" + "## State Hub Integration\n\n" + "| Context | URL |\n" + "|---------|-----|\n" + "| Local workstation | `http://127.0.0.1:8000` |\n" + "| Remote (railiance01, in-cluster) | `http://127.0.0.1:18000` |\n\n" + "More prose.\n" + ) + updated, action = apply_hub_access_block(original) + assert action == "replaced-table" + assert "127.0.0.1:18000" not in updated.split("Do not instruct")[0] + again, second = apply_hub_access_block(updated) + assert second == "noop" + assert again == updated + + +def test_scaffold_writes_canonical_access_table(tmp_path: Path) -> None: + dest = tmp_path / "access-tool" + result = scaffold_repository(dest, flavor="tooling", commit=False) + assert result.status == "applied" + agents = (dest / "AGENTS.md").read_text(encoding="utf-8") + assert "" in agents + assert "http://10.43.68.154:8000" in agents + assert "| Remote (railiance01, in-cluster) | `http://127.0.0.1:18000` |" not in agents + + +def test_refresh_hub_access_rewrites_stale_table(tmp_path: Path) -> None: + dest = tmp_path / "stale-repo" + dest.mkdir() + (dest / "AGENTS.md").write_text( + "# Agent instructions — stale-repo\n\n" + "## State Hub Integration\n\n" + "| Context | URL |\n" + "|---------|-----|\n" + "| Local workstation | `http://127.0.0.1:8000` |\n" + "| Remote (railiance01, in-cluster) | `http://127.0.0.1:18000` |\n", + encoding="utf-8", + ) + result = refresh_hub_access(dest, commit=False) + assert result.status == "applied" + assert result.evidence["action"] == "replaced-table" + text = (dest / "AGENTS.md").read_text(encoding="utf-8") + assert "10.43.68.154:8000" in text + assert "| Remote (railiance01, in-cluster) | `http://127.0.0.1:18000` |" not in text + + +def test_cli_refresh_hub_access(tmp_path: Path) -> None: + dest = tmp_path / "cli-refresh" + dest.mkdir() + (dest / "AGENTS.md").write_text("# Agent instructions\n", encoding="utf-8") + assert ( + main(["scaffold", "--path", str(dest), "--refresh-hub-access", "--no-commit"]) + == 0 + ) + text = (dest / "AGENTS.md").read_text(encoding="utf-8") + assert "" in text + assert "10.43.68.154:8000" in text diff --git a/tests/test_registrar_reconcile.py b/tests/test_registrar_reconcile.py index 478d479..30d2482 100644 --- a/tests/test_registrar_reconcile.py +++ b/tests/test_registrar_reconcile.py @@ -177,6 +177,101 @@ def test_rejects_dirty_or_unsynced_repository(tmp_path: Path, monkeypatch) -> No dirty = rr.registrar_reconcile(repo, confirm_primary=True) assert dirty.status == "rejected" assert dirty.error and dirty.error["code"] == "git_precondition_failed" + assert "note.txt" in (dirty.error.get("message") or "") + + +def test_generated_index_alone_does_not_fail_git_precondition(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + (repo / "WORK-RECORDS.md").write_text("# generated\n", encoding="utf-8") + monkeypatch.setattr( + rr, + "_check_primary", + lambda _api, **_: ({"status": "ok", "db": "connected", "instance_role": "primary"}, None), + ) + + def fake_run(command, *, env): + 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 "WORK-RECORDS.md" in result.evidence.get("committed_paths", []) + + +def test_incomplete_run_commits_writebacks_and_names_the_record( + tmp_path: Path, monkeypatch +) -> None: + repo = _fixture(tmp_path) + monkeypatch.setattr( + rr, + "_check_primary", + lambda _api, **_: ({"status": "ok", "db": "connected", "instance_role": "primary"}, None), + ) + + def fake_run(command, *, env): + 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---', + ) + workplan.write_text(text, encoding="utf-8") + (repo / "WORK-RECORDS.md").write_text("# generated after mint\n", encoding="utf-8") + return subprocess.CompletedProcess( + command, + 1, + "! task DEMO-WP-0001-T01 not created: 500 Internal Server Error: Internal Server Error", + "", + ) + + monkeypatch.setattr(rr, "_run_statehub", fake_run) + result = rr.registrar_reconcile(repo, statehub_bin="statehub", confirm_primary=True) + assert result.status == "failed" + assert result.error and result.error["code"] == "registration_incomplete" + assert "DEMO-WP-0001-T01" in (result.error.get("message") or "") + assert result.error.get("records") == ["DEMO-WP-0001-T01"] + assert "not created:" in (result.error.get("message") or "") + 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" + workplan = (repo / "workplans" / "DEMO-WP-0001.md").read_text(encoding="utf-8") + assert "11111111-1111-4111-8111-111111111111" in workplan + + +def test_bootstrap_source_invalid_names_the_record(tmp_path: Path, monkeypatch) -> None: + repo = _fixture(tmp_path) + monkeypatch.setattr( + rr, + "_check_primary", + lambda _api, **_: ({"status": "ok", "db": "connected", "instance_role": "primary"}, None), + ) + monkeypatch.setattr( + rr, + "_check_empty_repo_projection", + lambda _api, _slug: ({"repo_id": "repo-1", "workplan_count": 0}, None), + ) + result = rr.registrar_reconcile( + repo, + statehub_bin="statehub", + confirm_primary=True, + bootstrap_empty_projection=True, + ) + assert result.status == "rejected" + assert result.error and result.error["code"] == "bootstrap_source_invalid" + assert "DEMO-WP-0001" in (result.error.get("message") or "") + assert result.error.get("record") == "DEMO-WP-0001" def test_missing_scan_includes_intakes_and_decisions(tmp_path: Path) -> None: diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index b45d588..d228d95 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -75,3 +75,5 @@ def test_cli_scaffold(tmp_path: Path): == 0 ) assert (dest / "INTENT.md").is_file() + agents = (dest / "AGENTS.md").read_text(encoding="utf-8") + assert "10.43.68.154:8000" in agents