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
This commit is contained in:
tegwick 2026-08-28 20:48:21 +02:00
parent 77caca1ab8
commit 7ea7690dfc
10 changed files with 480 additions and 44 deletions

View file

@ -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 adds severe call latency; the full administrative MCP surface remains available
to clients that need it. to clients that need it.
<!-- BEGIN STATE-HUB-ACCESS -->
| Context | URL | | Context | URL |
|---------|-----| |---------|-----|
| Local workstation | `http://127.0.0.1:8000` | | Local workstation | `http://127.0.0.1:8000` |
| Remote (railiance01, in-cluster) | `http://10.43.68.154: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.
<!-- END STATE-HUB-ACCESS -->
When an operator has enabled the edge relay, set API_BASE to the relay URL. 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 Queueable writes return an explicit queued receipt if the central hub is

View file

@ -48,6 +48,7 @@ rmgr rapp place --path ../rapp-some-app --reef reef-railiance
rmgr conform --path . rmgr conform --path .
rmgr prefix-uniqueness --root .. rmgr prefix-uniqueness --root ..
rmgr scaffold --path ../prj-example --flavor project --wp-prefix EX-WP --no-commit 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). Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).

View file

@ -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 <repo> --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

View file

@ -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 = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files")
p_scaf.add_argument("--path", required=True) 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("--slug", default=None)
p_scaf.add_argument("--domain", default="infotech") p_scaf.add_argument("--domain", default="infotech")
p_scaf.add_argument("--wp-prefix", default=None) p_scaf.add_argument("--wp-prefix", default=None)
p_scaf.add_argument("--force", action="store_true") p_scaf.add_argument("--force", action="store_true")
p_scaf.add_argument("--no-commit", 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( p_provenance = sub.add_parser(
"assistant-provenance", "assistant-provenance",
@ -742,17 +751,23 @@ def main(argv: list[str] | None = None) -> int:
return 0 if report.get("ok") else 1 return 0 if report.get("ok") else 1
if args.command == "scaffold": 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( if args.refresh_hub_access and not args.flavor:
Path(args.path), result = refresh_hub_access(Path(args.path), commit=not args.no_commit)
flavor=args.flavor, else:
slug=args.slug, if not args.flavor:
domain=args.domain, print("scaffold: --flavor is required unless --refresh-hub-access is set", file=sys.stderr)
wp_prefix=args.wp_prefix, return 2
force=args.force, result = scaffold_repository(
commit=not args.no_commit, 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)) print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1 return 0 if result.status == "applied" else 1

View file

@ -26,6 +26,7 @@ from repo_manager.record_identity import scan_record_identities
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock") LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
STATEHUB_TIMEOUT_SECONDS = 900 STATEHUB_TIMEOUT_SECONDS = 900
GENERATED_INDEX_PATHS = frozenset({".custodian-brief.md", "WORK-RECORDS.md"})
@dataclass @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]: def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
status = _git(repo, "status", "--porcelain") status = _git(repo, "status", "--porcelain")
if status.returncode != 0: if status.returncode != 0:
return {}, status.stderr.strip() or "not a Git repository" return {}, status.stderr.strip() or "not a Git repository"
if status.stdout.strip(): dirty = _porcelain_paths(status.stdout)
return {}, "worktree must be clean before registrar reconciliation" 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}") upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if upstream.returncode != 0: if upstream.returncode != 0:
@ -210,6 +251,7 @@ def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
"behind": behind, "behind": behind,
"ahead": ahead, "ahead": ahead,
"origin": remote_url, "origin": remote_url,
"ignored_generated": generated,
}, None }, 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]: 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: if not paths:
return [], None return [], None
if paths != [".custodian-brief.md"]: generated = [path for path in paths if path in GENERATED_INDEX_PATHS]
return [], "projection repair changed files other than the generated Custodian brief" other = [path for path in paths if path not in GENERATED_INDEX_PATHS]
original = _git(repo, "show", "HEAD:.custodian-brief.md") if other:
if original.returncode != 0: return [], (
return [], original.stderr.strip() or "could not read the committed Custodian brief" "projection repair changed files other than generated indexes: "
(repo / ".custodian-brief.md").write_text(original.stdout, encoding="utf-8") + ", ".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") remaining = _git(repo, "status", "--porcelain")
if remaining.returncode != 0 or remaining.stdout.strip(): leftover = [
return [], remaining.stderr.strip() or "generated brief restoration left a dirty worktree" path
return paths, None 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( def registrar_reconcile(
@ -682,12 +739,14 @@ def registrar_reconcile(
"decisions": len(bootstrap_records["decision"]), "decisions": len(bootstrap_records["decision"]),
} }
if source_error: if source_error:
return RegistrarResult( error: dict[str, Any] = {
"rejected", "code": "bootstrap_source_invalid",
evidence, "message": source_error,
{"code": "bootstrap_source_invalid", "message": source_error}, }
cid, 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) LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOCK_PATH.open("a+", encoding="utf-8") as lock: 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 ( mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
bootstrap_empty_projection and not bootstrap_verified bootstrap_empty_projection and not bootstrap_verified
) )
if ( incomplete = (
completed.returncode not in accepted_exit_codes completed.returncode not in accepted_exit_codes
or any(after.values()) or any(after.values())
or mode_verification_failed 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") 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: if (repair_verified or bootstrap_verified) and paths:
restored, restore_error = _restore_generated_brief(repo, paths) restored, restore_error = _restore_generated_brief(repo, paths)
evidence["restored_generated_paths"] = restored evidence["restored_generated_paths"] = restored
@ -827,6 +877,7 @@ def registrar_reconcile(
paths, paths,
"chore(registrar): assign State Hub identifiers", "chore(registrar): assign State Hub identifiers",
) )
evidence["committed_paths"] = paths
except GitError as exc: except GitError as exc:
return RegistrarResult( return RegistrarResult(
"failed", "failed",
@ -838,7 +889,7 @@ def registrar_reconcile(
if push: if push:
pushed, message = push_ff(repo) pushed, message = push_ff(repo)
evidence["push"] = {"ok": pushed, "message": message} evidence["push"] = {"ok": pushed, "message": message}
if not pushed: if not pushed and not incomplete:
return RegistrarResult( return RegistrarResult(
"failed", "failed",
evidence, evidence,
@ -848,4 +899,19 @@ def registrar_reconcile(
evidence["workplan_bindings"] = _sync_workplan_bindings(repo, api_base, repo.name) 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) return RegistrarResult("applied", evidence, None, cid)

View file

@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any from typing import Any
from repo_manager.gitops import GitError, commit_paths, is_git_repo 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.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix
from repo_manager.time import utc_today from repo_manager.time import utc_today
@ -121,7 +122,9 @@ def scaffold_repository(
"AGENTS.md", "AGENTS.md",
f"# Agent instructions — {slug}\n\n" f"# Agent instructions — {slug}\n\n"
f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\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: if prj:
today = utc_today().isoformat() today = utc_today().isoformat()
@ -190,3 +193,36 @@ def scaffold_repository(
if not written: if not written:
evidence["noop"] = True evidence["noop"] = True
return CommandResult("applied", evidence, None, cid) 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)

View file

@ -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 = "<!-- BEGIN STATE-HUB-ACCESS -->"
END = "<!-- END STATE-HUB-ACCESS -->"
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 []]

87
tests/test_hub_access.py Normal file
View file

@ -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 "<!-- BEGIN STATE-HUB-ACCESS -->" 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 "<!-- BEGIN STATE-HUB-ACCESS -->" in text
assert "10.43.68.154:8000" in text

View file

@ -177,6 +177,101 @@ def test_rejects_dirty_or_unsynced_repository(tmp_path: Path, monkeypatch) -> No
dirty = rr.registrar_reconcile(repo, confirm_primary=True) dirty = rr.registrar_reconcile(repo, confirm_primary=True)
assert dirty.status == "rejected" assert dirty.status == "rejected"
assert dirty.error and dirty.error["code"] == "git_precondition_failed" 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: def test_missing_scan_includes_intakes_and_decisions(tmp_path: Path) -> None:

View file

@ -75,3 +75,5 @@ def test_cli_scaffold(tmp_path: Path):
== 0 == 0
) )
assert (dest / "INTENT.md").is_file() assert (dest / "INTENT.md").is_file()
agents = (dest / "AGENTS.md").read_text(encoding="utf-8")
assert "10.43.68.154:8000" in agents