From 03c7924b7fa231315b8d409d4756f5a7c12327bc Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 12:07:08 +0200 Subject: [PATCH] feat(consistency): bootstrap empty repo projections Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d --- custodian_cli.py | 7 +++ scripts/consistency_check.py | 81 +++++++++++++++++++++--- tests/test_consistency_check.py | 106 ++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 7 deletions(-) diff --git a/custodian_cli.py b/custodian_cli.py index 06f4320..2a791a3 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -402,6 +402,8 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None: cmd.append("--remote") if args.no_writeback: cmd.append("--no-writeback") + if getattr(args, "bootstrap_empty_projection", False): + cmd.append("--bootstrap-empty-projection") if args.archive_closed: cmd.append("--archive-closed") if args.archive_workplan: @@ -720,6 +722,11 @@ def main() -> None: fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all") fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all") fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback") + fix.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help="Rebuild file UUIDs only after proving the repo projection is empty", + ) fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing") fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename") fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed") diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 563f69c..c222be0 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -1154,7 +1154,12 @@ def _check_work_record_index_freshness( ) -def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = None) -> ConsistencyReport: +def check_repo( + api_base: str, + repo_slug: str, + repo_path_override: str | None = None, + bootstrap_empty_projection: bool = False, +) -> ConsistencyReport: """Run all consistency checks for a registered repo.""" repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True) if isinstance(repo, dict) and "_error" in repo: @@ -1180,6 +1185,28 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N repo_path: str = resolve_repo_path(repo, repo_path_override) report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path) + if bootstrap_empty_projection: + projected = _api_get(api_base, "/workplans", {"repo_id": repo_id}, return_error=True) + if not isinstance(projected, list): + report.add( + severity="FAIL", + check_id="C-36", + message="Could not prove that the repository projection is empty", + fixable=False, + ) + return report + if projected: + report.add( + severity="FAIL", + check_id="C-36", + message=( + "Empty-projection bootstrap refused: repository already has " + f"{len(projected)} projected workplan(s)" + ), + fixable=False, + ) + return report + if not repo_path: report.add( severity="FAIL", check_id="C-00", @@ -1328,14 +1355,21 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N ws = _api_get(api_base, f"/workplans/{ws_id}") if ws is None: wp_id = str(meta.get("id", "")).strip() - if wp_id and ws_id == _derived_work_record_uuid(wp_id): + if wp_id and ( + ws_id == _derived_work_record_uuid(wp_id) or bootstrap_empty_projection + ): # A deterministic file identifier missing from a replaceable # projection is registration work, not a stale reference. report.add( severity="WARN", check_id="C-06", message=( - f"Derived workplan {ws_id[:8]}… is absent from this projection" + f"Authoritative workplan {ws_id[:8]}… is absent from this " + + ( + "explicitly empty projection" + if bootstrap_empty_projection + else "projection" + ) ), file_path=fname, db_id=ws_id, @@ -2755,10 +2789,16 @@ def fix_repo( repo_slug: str, repo_path_override: str | None = None, no_writeback: bool = False, + bootstrap_empty_projection: bool = False, ) -> ConsistencyReport: """Run checks then apply all auto-fixable issues. Returns updated report.""" - report = check_repo(api_base, repo_slug, repo_path_override) - if any(i.check_id == "C-00" for i in report.failures): + report = check_repo( + api_base, + repo_slug, + repo_path_override, + bootstrap_empty_projection=bootstrap_empty_projection, + ) + if any(i.check_id in {"C-00", "C-36"} for i in report.failures): return report # RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots @@ -3675,6 +3715,14 @@ def main() -> None: f"(default: {DEFAULT_REMOTE_ALL_MAX_SECONDS}; 0 disables)") parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", help="Disable DB→file status writeback (C-15) while keeping other fixes") + parser.add_argument( + "--bootstrap-empty-projection", + action="store_true", + help=( + "Rebuild authoritative legacy UUIDs only after proving the target " + "repository has zero projected workplans" + ), + ) parser.add_argument("--archive-closed", action="store_true", help="Move closed root workplans to workplans/archived/YYMMDD-*.md") parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None, @@ -3712,6 +3760,11 @@ def main() -> None: import os as _os no_wb = getattr(args, "no_writeback", False) do_fix = args.fix or args.remote + if args.bootstrap_empty_projection and (not do_fix or args.all or args.remote): + parser.error( + "--bootstrap-empty-projection requires --fix for one local repository " + "and cannot be combined with --all or --remote" + ) # --here: infer slug from git remote URL, then run as single-repo check/fix if args.here is not None: @@ -3730,7 +3783,15 @@ def main() -> None: inferred_slug, git_root = inferred print(f" Detected: {inferred_slug} ({git_root})") if do_fix: - reports = [fix_repo(args.api_base, inferred_slug, git_root, no_writeback=no_wb)] + reports = [ + fix_repo( + args.api_base, + inferred_slug, + git_root, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) + ] else: reports = [check_repo(args.api_base, inferred_slug, git_root)] if args.archive_closed: @@ -3792,7 +3853,13 @@ def main() -> None: reports = [report] elif do_fix: reports = [ - fix_repo(args.api_base, slug, path_override, no_writeback=no_wb) + fix_repo( + args.api_base, + slug, + path_override, + no_writeback=no_wb, + bootstrap_empty_projection=args.bootstrap_empty_projection, + ) for slug in repo_slugs ] else: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 5d3b5fd..d7c752e 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -1272,6 +1272,112 @@ class TestC20DependencyDetection: class TestC06WorkstreamCreation: + def test_fix_repo_bootstraps_legacy_ids_only_into_empty_projection( + self, tmp_path, monkeypatch + ): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + workplan_id = "11111111-1111-4111-8111-111111111111" + task_id = "22222222-2222-4222-8222-222222222222" + wp = workplans / "DEMO-WP-0001.md" + wp.write_text( + "---\n" + "id: DEMO-WP-0001\n" + "type: workplan\n" + "title: Demo Workplan\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: ready\n" + f'state_hub_workstream_id: "{workplan_id}"\n' + "---\n\n" + "## Task\n\n" + "```task\n" + "id: DEMO-WP-0001-T01\n" + "status: todo\n" + "priority: high\n" + f'state_hub_task_id: "{task_id}"\n' + "```\n", + encoding="utf-8", + ) + created = [] + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {}, + "domain_slug": "financials", + } + if path == "/topics": + return [{"id": "topic-1", "domain_slug": "financials"}] + if path == "/workplans" and params == {"repo_id": "repo-1"}: + return [] + if path == f"/workplans/{workplan_id}": + return None + if path == "/workplans" and params and "slug" in params: + return [] + return [] + + def fake_post(_api_base, path, body): + created.append((path, body)) + return body + + monkeypatch.setattr("consistency_check._api_get", fake_get) + monkeypatch.setattr("consistency_check._api_post", fake_post) + monkeypatch.setattr("consistency_check._api_patch", lambda *args, **kwargs: {"ok": True}) + monkeypatch.setattr("consistency_check._detect_behind_remote", lambda _repo_path: False) + monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0) + monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False) + monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed")) + monkeypatch.setenv("STATEHUB_REGISTRAR", "1") + + report = fix_repo( + "http://unused", + "demo-repo", + bootstrap_empty_projection=True, + ) + + assert [body["id"] for path, body in created if path == "/workplans"] == [workplan_id] + assert [body["id"] for path, body in created if path == "/tasks"] == [task_id] + assert any("explicitly empty projection" in issue.message for issue in report.issues) + + def test_fix_repo_refuses_empty_bootstrap_when_projection_has_rows( + self, tmp_path, monkeypatch + ): + repo = tmp_path / "repo" + (repo / "workplans").mkdir(parents=True) + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {}, + "domain_slug": "financials", + } + if path == "/workplans" and params == {"repo_id": "repo-1"}: + return [{"id": "existing"}] + return [] + + monkeypatch.setattr("consistency_check._api_get", fake_get) + monkeypatch.setattr( + "consistency_check._api_post", + lambda *_args, **_kwargs: pytest.fail("bootstrap must not mutate a non-empty projection"), + ) + + report = fix_repo( + "http://unused", + "demo-repo", + bootstrap_empty_projection=True, + ) + + assert len(report.failures) == 1 + assert report.failures[0].check_id == "C-36" + def test_fix_repo_rebuilds_missing_projection_from_derived_file_ids( self, tmp_path, monkeypatch ):