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:
parent
77caca1ab8
commit
7ea7690dfc
10 changed files with 480 additions and 44 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
19
config/state-hub-access.yaml
Normal file
19
config/state-hub-access.yaml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
111
src/repo_manager/hub_access.py
Normal file
111
src/repo_manager/hub_access.py
Normal 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
87
tests/test_hub_access.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue