diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index 49b9bed..2d2e8ce 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -249,166 +249,6 @@ def rm_update_task_status( return result -def rm_update_workplan( - *, - repo_path: str | Path, - workplan_id: str, - operation: str = "update", - title: str | None = None, - goal: str | None = None, - status: str | None = None, - owner: str | None = None, - domain: str | None = None, - topic_slug: str | None = None, - repo_slug: str | None = None, - reason: str = "state-hub dual-run", - push: bool | None = None, - correlation_id: str | None = None, - confirm_archive: bool = False, -) -> dict[str, Any]: - """Delegate a file-backed workplan mutation to Repo Manager. - - ``operation=archive`` is the recoverable counterpart of State Hub's - DELETE workplan route; it never erases repository history. - """ - correlation_id = correlation_id or str(uuid.uuid4()) - if operation not in {"create", "update", "archive"}: - return { - "status": "rejected", - "correlation_id": correlation_id, - "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, - } - if push is None: - push = writeback_push_enabled() - cli_operation = "delete" if operation == "archive" else operation - args = [ - "workplan", - cli_operation, - "--path", - str(repo_path), - "--workplan-id", - str(workplan_id), - "--reason", - reason, - "--correlation-id", - correlation_id, - "--idempotency-key", - f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}", - ] - for flag, value in ( - ("--title", title), - ("--goal", goal), - ("--status", status), - ("--owner", owner), - ("--domain", domain), - ("--topic-slug", topic_slug), - ("--slug", repo_slug), - ): - if value is not None: - args.extend([flag, value]) - if operation == "archive" and confirm_archive: - args.append("--confirm") - if push: - args.append("--push") - - code, out, err = run_rmgr(args) - try: - result = json.loads(out.strip() or "{}") - except json.JSONDecodeError: - result = { - "status": "failed", - "error": { - "code": "internal", - "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", - }, - "correlation_id": correlation_id, - } - if code != 0 and result.get("status") not in ("applied", "rejected"): - result.setdefault("status", "failed") - result.setdefault( - "error", - {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, - ) - return result - - -def rm_update_register_entry( - *, - repo_path: str | Path, - kind: str, - entry_id: str, - operation: str = "put", - title: str | None = None, - status: str | None = None, - data: dict[str, Any] | None = None, - note: str | None = None, - author: str | None = None, - repo_slug: str | None = None, - reason: str = "state-hub retirement adapter", - push: bool | None = None, - correlation_id: str | None = None, -) -> dict[str, Any]: - """Delegate register create/update/defer/note to Repo Manager.""" - correlation_id = correlation_id or str(uuid.uuid4()) - if operation not in {"put", "defer", "note"}: - return { - "status": "rejected", - "correlation_id": correlation_id, - "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, - } - if push is None: - push = writeback_push_enabled() - args = [ - "register", - operation, - "--path", - str(repo_path), - "--kind", - kind, - "--entry-id", - entry_id, - "--reason", - reason, - "--correlation-id", - correlation_id, - "--idempotency-key", - f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}", - ] - for flag, value in ( - ("--title", title), - ("--status", status), - ("--note", note), - ("--author", author), - ("--slug", repo_slug), - ): - if value is not None: - args.extend([flag, value]) - if data is not None: - args.extend(["--data-json", json.dumps(data, separators=(",", ":"))]) - if push: - args.append("--push") - - code, out, err = run_rmgr(args) - try: - result = json.loads(out.strip() or "{}") - except json.JSONDecodeError: - result = { - "status": "failed", - "error": { - "code": "internal", - "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", - }, - "correlation_id": correlation_id, - } - if code != 0 and result.get("status") not in ("applied", "rejected"): - result.setdefault("status", "failed") - result.setdefault( - "error", - {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, - ) - return result - - def rm_scaffold( *, repo_path: str | Path, diff --git a/tests/test_repo_manager_workplan_adapter.py b/tests/test_repo_manager_workplan_adapter.py deleted file mode 100644 index 5accfb3..0000000 --- a/tests/test_repo_manager_workplan_adapter.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Compatibility adapter coverage for RMGR-WP-0008-T01.""" - -from api.services import repo_manager_dual_run as adapter - - -def test_rm_update_workplan_builds_governed_cli_command(monkeypatch): - seen = {} - - def fake_run(args, *, timeout=120): - seen["args"] = args - return 0, '{"status":"applied","evidence":{"git_sha":"abc"}}', "" - - monkeypatch.setattr(adapter, "run_rmgr", fake_run) - result = adapter.rm_update_workplan( - repo_path="/repos/demo", - workplan_id="DEMO-WP-0001", - operation="update", - title="Renamed", - status="active", - repo_slug="demo", - correlation_id="00000000-0000-4000-8000-000000000001", - push=True, - ) - - assert result["status"] == "applied" - assert seen["args"][:5] == [ - "workplan", - "update", - "--path", - "/repos/demo", - "--workplan-id", - ] - assert "--title" in seen["args"] - assert "--status" in seen["args"] - assert "--push" in seen["args"] - - -def test_rm_update_workplan_maps_delete_to_confirmed_archive(monkeypatch): - seen = {} - - def fake_run(args, *, timeout=120): - seen["args"] = args - return 0, '{"status":"applied"}', "" - - monkeypatch.setattr(adapter, "run_rmgr", fake_run) - result = adapter.rm_update_workplan( - repo_path="/repos/demo", - workplan_id="DEMO-WP-0001", - operation="archive", - confirm_archive=True, - push=False, - ) - - assert result["status"] == "applied" - assert seen["args"][0:2] == ["workplan", "delete"] - assert "--confirm" in seen["args"] - assert "--push" not in seen["args"] - - -def test_rm_update_workplan_rejects_unknown_operation_without_invocation(monkeypatch): - def fail_run(*args, **kwargs): - raise AssertionError("rmgr must not run") - - monkeypatch.setattr(adapter, "run_rmgr", fail_run) - result = adapter.rm_update_workplan( - repo_path="/repos/demo", - workplan_id="DEMO-WP-0001", - operation="erase", - ) - assert result["status"] == "rejected" - assert result["error"]["code"] == "validation_error" - - -def test_rm_update_register_entry_builds_shared_spine_command(monkeypatch): - seen = {} - - def fake_run(args, *, timeout=120): - seen["args"] = args - return 0, '{"status":"applied"}', "" - - monkeypatch.setattr(adapter, "run_rmgr", fake_run) - result = adapter.rm_update_register_entry( - repo_path="/repos/demo", - kind="technical-debt", - entry_id="TD-001", - operation="put", - title="Debt", - data={"severity": "high"}, - push=False, - ) - - assert result["status"] == "applied" - assert seen["args"][0:2] == ["register", "put"] - assert "technical-debt" in seen["args"] - assert '{"severity":"high"}' in seen["args"] diff --git a/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md b/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md deleted file mode 100644 index aa57338..0000000 --- a/workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md +++ /dev/null @@ -1,230 +0,0 @@ ---- -id: STATE-WP-0081 -type: workplan -title: "Cluster self-sufficiency: remove workstation coupling and fix the registrar" -domain: infotech -repo: state-hub -status: proposed -owner: codex -topic_slug: infotech -created: "2026-08-21" -updated: "2026-08-21" -parent_project: prj-state-hub-retirement -parent_workplan: SHR-WP-0001 -related: - - STATE-WP-0079 - - RMGR-WP-0005 - - RMGR-WP-0008 - - ADR-007 - - ADR-010 ---- - -# Cluster self-sufficiency: remove workstation coupling and fix the registrar - -## Goal - -Make the railiance01-hosted State Hub stand on its own: its own clones, its own -git identity, no hostPath into an operator's home directory, and no dependence -on workstation paths, processes, or checkouts. Fix the identifier registrar as -part of that, because the registrar is broken *by* this coupling rather than -beside it. - -End state: **workstation coding agents push to forgejo; cluster infrastructure -reads from forgejo. Neither reads the other's disk.** - -## Admissibility under the retirement freeze - -`policies/retirement-freeze.md` allows changes that fix operational risk or -reduce scope. This is both: the sweep is currently broken in production, and the -work removes a coupling rather than adding capability. It establishes no new -permanent ownership here — the deployment chart already lives in this repo at -`deploy/railiance/apps/charts/state-hub/`. - -## The coupling, as measured 2026-08-21 - -| # | Coupling | Evidence | -| --- | --- | --- | -| 1 | Pod mounts the operator home as a hostPath | `sweep.hostPath: /home/tegwick`, mounted rw at the same path | -| 2 | Pod runs as root | `securityContext: {}`, no `runAsUser`; produced **999 root-owned files** under `~/state-hub` | -| 3 | Pod uses the operator's personal SSH key as its service identity | `sweep.sshHostPath: /home/tegwick/.ssh` → `/root/.ssh` | -| 4 | Hub repo registry points at workstation paths | **72 of 75** records have `local_path: /home/worsch/...`, a path railiance01 will never have | -| 5 | Hub repo registry records the retired git host | `remote_url: gitea-remote:...` after the 2026-08-21 forgejo transition | -| 6 | Dashboard is a workstation process | Observable dev server on `127.0.0.1:3000`, not served from the cluster | - -**And the fault that blocks everything:** inside the pod `/home/tegwick` is -mounted **read-only** — `ro,relatime,discard,errors=remount-ro` — although the -chart sets no `readOnly` on that volumeMount and `securityContext` is empty. The -host has the same device mounted `rw` with a healthy disk and no dmesg errors, so -this is imposed by the runtime or an admission path, not by hardware. - -The sweep therefore dies with -`OSError: [Errno 30] Read-only file system: '/home/tegwick/info-tech-canon/.custodian-brief.md'` -before it can mint anything. That is the whole registrar problem: **the registrar -cannot write identifiers into files it cannot write.** It explains the 12 queued -sync requests and why the newest `custodian-sync` commits are from July. - -## Restore the write path - -```task -id: STATE-WP-0081-T01 -status: todo -priority: high -``` - -Find why the hostPath mounts read-only against the chart's own spec, and restore -writes. Candidates in order: an admission controller or PodSecurity policy -forcing hostPath read-only; a k3s/containerd default for hostPath volumes; a -missing explicit `readOnly: false` on the volumeMount. - -This is the minimum to make the registrar function, and the only task here that -unblocks anything today. Do it first even though T02 later deletes the mount — -a working baseline makes every later change verifiable. - -Verification is end-to-end, not a green pod: run the sweep and confirm -`EBIND-WP-0002` gets a `state_hub_workstream_id` written back into its file. - -## Give the pod its own clones - -```task -id: STATE-WP-0081-T02 -status: todo -priority: high -``` - -Replace the `sweep-repos` hostPath with storage the pod owns — a PVC the sweep -clones into and maintains, seeded from forgejo. - -This is the change that actually severs coupling #1 and #2. Mounting a human's -home directory into a production workload is what created 999 root-owned files, -what made `git pull` fail on the host, and what put the operator's checkouts one -`reset --hard` away from a scheduled job. - -Sizing input: the current tree is ~78 repos; `markitect_project` alone is 24 MB. - -Keep the sweep's repo list driven by the hub's repo registry, not by whatever -happens to be on a disk. - -## Replace the operator SSH key with a service identity - -```task -id: STATE-WP-0081-T03 -status: todo -priority: high -``` - -Issue a dedicated forgejo deploy key or service account for the sweep and mount -it from a Secret. Remove `sweep.sshHostPath` and the `/root/.ssh` mount. - -The current arrangement gives a root-running production workload the operator's -personal private key. It is read-only, so this is a blast-radius problem rather -than a live compromise — but the key that can push to every repository in the -fleet should not be the same key a human uses interactively. - -Credential custody routes through OpenBao, not this repo — see -`.claude/rules/credential-routing.md`. Do not put key material in the chart. - -## Run as a non-root user - -```task -id: STATE-WP-0081-T04 -status: todo -priority: medium -``` - -Set `runAsUser`/`runAsGroup` and a `fsGroup` matching the PVC. Depends on T02: -once the pod owns its storage there is no reason for it to be root. - -Closes the recurrence: today's ownership fix will be undone by the next sweep -while the pod still runs as root. - -## Correct the hub repo registry - -```task -id: STATE-WP-0081-T05 -status: todo -priority: high -``` - -72 of 75 repo records carry `local_path: /home/worsch/...` and stale -`remote_url: gitea-remote:...`. Both are wrong for a cluster that reads from -forgejo into its own clone tree. - -Decide first whether `local_path` should be **per-instance rather than global** — -one column cannot describe a workstation checkout and a cluster clone at once, -and its current single value is precisely how the workstation leaked into -cluster configuration. `RMGR-WP-0008` is building the repository-representation -surface that inherits this; settle the shape with it rather than patching values -that will move. - -`remote_url` correction is unambiguous and can proceed immediately. - -## Serve the dashboard from the cluster - -```task -id: STATE-WP-0081-T06 -status: todo -priority: medium -``` - -The dashboard runs as an Observable dev server on the workstation at -`127.0.0.1:3000`. Serve it from the cluster behind the same ingress as the API so -it survives the workstation being off, and so what the operator sees is what the -cluster holds. - -Check first whether this is worth building here at all: `hub-projection-ui` is -dispositioned `replace` → `hub-core` (14 items, slice B5 in -`docs/retirement-cutover-slice-plan.md`). If B5 lands first this task is a -redirect, not a build. Confirm with `HUB-WP-0004` before writing any chart. - -## State and enforce the boundary - -```task -id: STATE-WP-0081-T07 -status: todo -priority: medium -``` - -Write the rule down so it stops being re-derived: **workstation coding agents -push to forgejo; cluster infrastructure reads from forgejo; neither reads the -other's disk.** - -Record it where agents will meet it — `docs/`, the repo `AGENTS.md` templates, and -as an ADR if it constrains other repos, which it does. - -Include the failure this prevents. On 2026-08-21 railiance01's 70 checkouts were -found still pointed at the retired gitea host, six weeks stale, and the -`evidence-binder` workplan the registrar was asked to index **did not exist** in -the copy the cluster could see. A shared-disk assumption made a stale reader look -like a queue. - -## Close out the registrar - -```task -id: STATE-WP-0081-T08 -status: todo -priority: high -``` - -With the write path restored, confirm the registrar end-to-end and drain the -backlog: 12 queued requests from `evidence-binder`, `kaizen-agentic`, -`glas-harness`, and `agentic-resources`, plus this fleet's own unregistered -`RMGR-WP-0008` and `RMGR-WP-0009`. - -Then remove the interim: `RMGR-WP-0005-T03` (UUIDv5 derivation, keyed on -`(namespace, identifier)` and deriving for live records only per the 2026-08-21 -`ADR-007` amendment) makes writeback idempotent and retires the single-writer -rule entirely. Coordinate rather than duplicate — the derivation belongs to -`repo-manager`. - -Reply to the queued agents when it is done; several have been waiting since -2026-08-20. - -## Acceptance - -- [ ] Sweep writes successfully from the pod; `EBIND-WP-0002` registered -- [ ] Pod uses its own clone volume; no hostPath into any home directory -- [ ] Pod authenticates with a dedicated key, runs as non-root -- [ ] No `/home/worsch` path in any cluster-consumed record; `remote_url` values current -- [ ] Dashboard reachable without the workstation, or formally handed to `hub-core` -- [ ] Boundary rule written and discoverable by agents -- [ ] Registrar queue drained; interim single-writer rule retired or explicitly deferred