feat(consistency): bootstrap empty repo projections
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
52a7d6bb73
commit
03c7924b7f
3 changed files with 187 additions and 7 deletions
|
|
@ -402,6 +402,8 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None:
|
||||||
cmd.append("--remote")
|
cmd.append("--remote")
|
||||||
if args.no_writeback:
|
if args.no_writeback:
|
||||||
cmd.append("--no-writeback")
|
cmd.append("--no-writeback")
|
||||||
|
if getattr(args, "bootstrap_empty_projection", False):
|
||||||
|
cmd.append("--bootstrap-empty-projection")
|
||||||
if args.archive_closed:
|
if args.archive_closed:
|
||||||
cmd.append("--archive-closed")
|
cmd.append("--archive-closed")
|
||||||
if args.archive_workplan:
|
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("--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("--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("--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-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-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")
|
fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed")
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
"""Run all consistency checks for a registered repo."""
|
||||||
repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True)
|
repo = _api_get(api_base, f"/repos/{repo_slug}", return_error=True)
|
||||||
if isinstance(repo, dict) and "_error" in repo:
|
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)
|
repo_path: str = resolve_repo_path(repo, repo_path_override)
|
||||||
report = ConsistencyReport(repo_slug=repo_slug, repo_path=repo_path)
|
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:
|
if not repo_path:
|
||||||
report.add(
|
report.add(
|
||||||
severity="FAIL", check_id="C-00",
|
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}")
|
ws = _api_get(api_base, f"/workplans/{ws_id}")
|
||||||
if ws is None:
|
if ws is None:
|
||||||
wp_id = str(meta.get("id", "")).strip()
|
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
|
# A deterministic file identifier missing from a replaceable
|
||||||
# projection is registration work, not a stale reference.
|
# projection is registration work, not a stale reference.
|
||||||
report.add(
|
report.add(
|
||||||
severity="WARN",
|
severity="WARN",
|
||||||
check_id="C-06",
|
check_id="C-06",
|
||||||
message=(
|
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,
|
file_path=fname,
|
||||||
db_id=ws_id,
|
db_id=ws_id,
|
||||||
|
|
@ -2755,10 +2789,16 @@ def fix_repo(
|
||||||
repo_slug: str,
|
repo_slug: str,
|
||||||
repo_path_override: str | None = None,
|
repo_path_override: str | None = None,
|
||||||
no_writeback: bool = False,
|
no_writeback: bool = False,
|
||||||
|
bootstrap_empty_projection: bool = False,
|
||||||
) -> ConsistencyReport:
|
) -> ConsistencyReport:
|
||||||
"""Run checks then apply all auto-fixable issues. Returns updated report."""
|
"""Run checks then apply all auto-fixable issues. Returns updated report."""
|
||||||
report = check_repo(api_base, repo_slug, repo_path_override)
|
report = check_repo(
|
||||||
if any(i.check_id == "C-00" for i in report.failures):
|
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
|
return report
|
||||||
|
|
||||||
# RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots
|
# 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)")
|
f"(default: {DEFAULT_REMOTE_ALL_MAX_SECONDS}; 0 disables)")
|
||||||
parser.add_argument("--no-writeback", action="store_true", dest="no_writeback",
|
parser.add_argument("--no-writeback", action="store_true", dest="no_writeback",
|
||||||
help="Disable DB→file status writeback (C-15) while keeping other fixes")
|
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",
|
parser.add_argument("--archive-closed", action="store_true",
|
||||||
help="Move closed root workplans to workplans/archived/YYMMDD-*.md")
|
help="Move closed root workplans to workplans/archived/YYMMDD-*.md")
|
||||||
parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None,
|
parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None,
|
||||||
|
|
@ -3712,6 +3760,11 @@ def main() -> None:
|
||||||
import os as _os
|
import os as _os
|
||||||
no_wb = getattr(args, "no_writeback", False)
|
no_wb = getattr(args, "no_writeback", False)
|
||||||
do_fix = args.fix or args.remote
|
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
|
# --here: infer slug from git remote URL, then run as single-repo check/fix
|
||||||
if args.here is not None:
|
if args.here is not None:
|
||||||
|
|
@ -3730,7 +3783,15 @@ def main() -> None:
|
||||||
inferred_slug, git_root = inferred
|
inferred_slug, git_root = inferred
|
||||||
print(f" Detected: {inferred_slug} ({git_root})")
|
print(f" Detected: {inferred_slug} ({git_root})")
|
||||||
if do_fix:
|
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:
|
else:
|
||||||
reports = [check_repo(args.api_base, inferred_slug, git_root)]
|
reports = [check_repo(args.api_base, inferred_slug, git_root)]
|
||||||
if args.archive_closed:
|
if args.archive_closed:
|
||||||
|
|
@ -3792,7 +3853,13 @@ def main() -> None:
|
||||||
reports = [report]
|
reports = [report]
|
||||||
elif do_fix:
|
elif do_fix:
|
||||||
reports = [
|
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
|
for slug in repo_slugs
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1272,6 +1272,112 @@ class TestC20DependencyDetection:
|
||||||
|
|
||||||
|
|
||||||
class TestC06WorkstreamCreation:
|
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(
|
def test_fix_repo_rebuilds_missing_projection_from_derived_file_ids(
|
||||||
self, tmp_path, monkeypatch
|
self, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue