diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index 49b9bed..4cc98f2 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -144,12 +144,12 @@ def record_mutation( detail: dict[str, Any] | None = None, ) -> None: try: - from datetime import datetime, timezone + from datetime import UTC, datetime path = meter_path() path.parent.mkdir(parents=True, exist_ok=True) row = { - "ts": datetime.now(timezone.utc).isoformat(), + "ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"), "source": source, "kind": kind, "repo_slug": repo_slug, @@ -409,6 +409,31 @@ def rm_update_register_entry( return result +def rm_scan_sbom( + *, + repo_path: str | Path, + repo_slug: str | None = None, +) -> dict[str, Any]: + """Derive a versioned SBOM snapshot from repository-owned sources.""" + args = ["sbom", "scan", "--path", str(repo_path)] + if repo_slug: + args.extend(["--slug", repo_slug]) + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "ok": False, + "error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + } + if code != 0: + result.setdefault("ok", False) + result.setdefault("error", f"rmgr exit={code} stderr={err!r}") + result["exit_code"] = code + return result + + def rm_scaffold( *, repo_path: str | Path, diff --git a/docs/retirement-cutover-slice-plan.md b/docs/retirement-cutover-slice-plan.md index 9b775f9..35de3b1 100644 --- a/docs/retirement-cutover-slice-plan.md +++ b/docs/retirement-cutover-slice-plan.md @@ -208,16 +208,13 @@ flow and needs a State Hub API restart to take effect. Piloting on | | | | --- | --- | | Flag | `RM_SLICE_REGISTERS` | -| Owner API | **does not exist** — see the readiness correction above | -| Rollback | n/a until there is something to roll back from | +| Owner API | `rmgr register ...`; `rmgr sbom scan|licence-report` | +| Rollback | flag → `off`; retain current State Hub projection | -**Blocked.** repo-manager has no register surface: no SBOM ingest, no repo -goals, no contributions, no technical-debt or extension-point registry. This -slice cannot start until a repo-manager workplan builds one. - -Low coupling and low write volume still make this the right slice to prove the -`RM_SLICE_*` pattern **once the owner side exists** — but that is a -repo-manager-side prerequisite, not a State Hub task. +**Owner ready (2026-08-21).** Finished RMGR-WP-0008 supplies the shared six-kind +register spine, governed compatibility adapter, lockfile-derived SBOM and +licence/copyleft report. Historical SBOM rows are cache provenance; rebuild +from current repository sources rather than copying them into authority. ### A4 · Work + repo UI — 22 items `work-coordination-ui` (14, `replace`) · `repo-ui` (8) @@ -225,11 +222,14 @@ repo-manager-side prerequisite, not a State Hub task. | | | | --- | --- | | Flag | `RM_SLICE_UI` | -| Owner API | owner-side dashboard; hub pages redirect | +| Owner API | hub-core projection/query plus hub/ops UI clients | | Rollback | remove redirect; hub pages are unchanged behind it | `replace`, not `move`: the contract changes. Must follow A1–A3, since the pages -render what those slices serve. +render what those slices serve. RMGR-ADR-003 corrects the earlier inventory +assignment: Repo Manager owns the repository contracts, not a dashboard. A4's +UI implementation therefore rides with B5 even though these 22 inventory items +remain grouped here for traceability. ### A5 · Topic spine — 8 items `topic-spine` (8, `replace`) @@ -240,14 +240,17 @@ render what those slices serve. | Owner API | `rmgr` topic/classification contract | | Rollback | flag → `off` | -Last in Wave A: it is a `replace` under a changed contract, and A1–A4 read it. +The versioned Repo Manager classification contract is published and accepted by +finished HUB-WP-0004. Consumer route switching remains a State Hub cutover +action. --- -## Wave B — hub-core (128 items) · gated on HUB-WP-0004 +## Wave B — hub-core (128 items) · HUB-WP-0004 owner gate satisfied -HUB-WP-0004 is `proposed`. **No hub-side adapter work until it is at least -`ready`.** Slices are listed to fix scope and order, not to start. +HUB-WP-0004 finished 2026-08-21 and accepted the project architecture, runtime, +ports, conformance scaffold, and absorption plan. Individual production +cutovers still require their slice evidence and operator gates. | Slice | Capabilities | Items | | --- | --- | --- | @@ -435,14 +438,14 @@ discovered live at cutover. 1. ~~Resolve the legacy-meter window discrepancy~~ — done 2026-08-20; E1 and the evidenced part of E2 are executed. -2. **Raise a `repo-manager` workplan for the register surface** (A3), the UI - surface (A4), and the topic-spine contract (A5). Until that exists, 79 of - Wave A's 165 items have nowhere to go. This is now the critical path for the - whole retirement, ahead of anything State Hub can do to itself. +2. ~~Build the Repo Manager receiving surface for A3/A5 and settle A4 + ownership~~ — finished in RMGR-WP-0008. A4 belongs to hub-core projection UI, + not Repo Manager. 3. Cut **A2a** (task status + reconcile, ~6 items) — the only slice in the plan executable today. First step is operational: create `~/.repo-manager/dual-run.yaml` and enable writeback/reconcile for one pilot repo, then watch the mutation meter. A2b–A2e need repo-manager parsers and adapters that do not exist. -4. Do not open Wave B until HUB-WP-0004 reaches `ready`; 125 items sit behind it. +4. HUB-WP-0004's owner-readiness gate is satisfied; advance Wave B only through + its per-slice dual-run and operator gates. 5. Do not build register, UI, or topic capability in State Hub to unblock items 2–3 — `policies/retirement-freeze.md` makes that inadmissible. diff --git a/tests/test_repo_manager_workplan_adapter.py b/tests/test_repo_manager_workplan_adapter.py index 5accfb3..3eb7cb4 100644 --- a/tests/test_repo_manager_workplan_adapter.py +++ b/tests/test_repo_manager_workplan_adapter.py @@ -93,3 +93,23 @@ def test_rm_update_register_entry_builds_shared_spine_command(monkeypatch): assert seen["args"][0:2] == ["register", "put"] assert "technical-debt" in seen["args"] assert '{"severity":"high"}' in seen["args"] + + +def test_rm_scan_sbom_builds_derived_snapshot_command(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return ( + 0, + '{"schema":"repo-manager.sbom-snapshot.v1","ok":true,"entry_count":3}', + "", + ) + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_scan_sbom(repo_path="/repos/demo", repo_slug="demo") + + assert result["ok"] is True + assert result["schema"] == "repo-manager.sbom-snapshot.v1" + assert result["exit_code"] == 0 + assert seen["args"] == ["sbom", "scan", "--path", "/repos/demo", "--slug", "demo"]