feat(identifiers): verify batch projections
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
e6cc18bf18
commit
055c6971ab
11 changed files with 2964 additions and 9 deletions
|
|
@ -318,6 +318,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
)
|
||||
p_id_batch.add_argument("--plan", required=True)
|
||||
p_id_batch.add_argument("--repo", action="append", required=True, dest="repos")
|
||||
p_id_batch.add_argument(
|
||||
"--projection-api-base",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="projection_api_bases",
|
||||
help="Require current UUID=200 and derived UUID=404 on this projection",
|
||||
)
|
||||
p_id_batch.add_argument("--output", default=None)
|
||||
p_id_batch.add_argument("--force", action="store_true")
|
||||
p_id_batch_verify = identifier_sub.add_parser(
|
||||
|
|
@ -772,7 +779,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
||||
if not isinstance(plan, dict):
|
||||
raise TypeError("migration plan must be a JSON object")
|
||||
result = plan_identifier_migration_batch(plan, repo_slugs=args.repos)
|
||||
result = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=args.repos,
|
||||
projection_api_bases=args.projection_api_bases,
|
||||
)
|
||||
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -551,7 +551,7 @@ def registrar_reconcile(
|
|||
bootstrap_verified = projection_error is None
|
||||
|
||||
requested_verified = False
|
||||
if completed.returncode == 1 and not any(after.values()):
|
||||
if completed.returncode == 1 and any(before.values()) and not any(after.values()):
|
||||
(
|
||||
requested_workplans,
|
||||
requested_tasks,
|
||||
|
|
@ -578,7 +578,14 @@ def registrar_reconcile(
|
|||
# A repository-scoped projection repair may coexist with legacy stale
|
||||
# references that correctly keep the broader consistency report red.
|
||||
accepted_exit_codes.add(1)
|
||||
if completed.returncode not in accepted_exit_codes or any(after.values()):
|
||||
mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
|
||||
bootstrap_empty_projection and not bootstrap_verified
|
||||
)
|
||||
if (
|
||||
completed.returncode not in accepted_exit_codes
|
||||
or any(after.values())
|
||||
or mode_verification_failed
|
||||
):
|
||||
return RegistrarResult(
|
||||
"failed",
|
||||
evidence,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from collections import defaultdict
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from repo_manager.cache import source_fingerprint
|
||||
|
|
@ -359,8 +360,79 @@ def _git_cutover_preflight(repo: Path, *, expected_head_sha: str | None) -> dict
|
|||
}
|
||||
|
||||
|
||||
def _projection_migration_preflight(
|
||||
mappings: list[dict[str, Any]], api_bases: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Require every replacement source and no replacement target per projection."""
|
||||
projections: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for raw_base in api_bases:
|
||||
api_base = raw_base.strip().rstrip("/")
|
||||
try:
|
||||
url = httpx.URL(api_base)
|
||||
if (
|
||||
url.scheme not in {"http", "https"}
|
||||
or not url.host
|
||||
or url.username
|
||||
or url.password
|
||||
or url.query
|
||||
or url.fragment
|
||||
):
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append(
|
||||
{"scope": raw_base, "reason": "projection API base must be a plain HTTP(S) origin"}
|
||||
)
|
||||
continue
|
||||
|
||||
checks: list[dict[str, Any]] = []
|
||||
try:
|
||||
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
|
||||
for mapping in mappings:
|
||||
if mapping.get("action") != "replace":
|
||||
continue
|
||||
route = "workplans" if mapping.get("kind") == "workplan" else "tasks"
|
||||
current = client.get(f"{api_base}/{route}/{mapping.get('current_uuid')}")
|
||||
derived = client.get(f"{api_base}/{route}/{mapping.get('derived_uuid')}")
|
||||
check_ok = current.status_code == 200 and derived.status_code == 404
|
||||
check = {
|
||||
"record_id": mapping.get("record_id"),
|
||||
"kind": mapping.get("kind"),
|
||||
"current_status": current.status_code,
|
||||
"derived_status": derived.status_code,
|
||||
"ok": check_ok,
|
||||
}
|
||||
checks.append(check)
|
||||
if not check_ok:
|
||||
errors.append(
|
||||
{
|
||||
"scope": f"{api_base}:{mapping.get('record_id')}",
|
||||
"reason": "projection requires current UUID=200 and derived UUID=404",
|
||||
}
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
errors.append({"scope": api_base, "reason": "projection API is unavailable"})
|
||||
projections.append(
|
||||
{
|
||||
"api_base": api_base,
|
||||
"ok": all(check["ok"] for check in checks),
|
||||
"replacement_checks": checks,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"required": True,
|
||||
"ok": bool(projections) and not errors,
|
||||
"projections": projections,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def plan_identifier_migration_batch(
|
||||
plan: dict[str, Any], *, repo_slugs: list[str]
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
repo_slugs: list[str],
|
||||
projection_api_bases: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pin a reviewed repository batch after source and Git preflight.
|
||||
|
||||
|
|
@ -379,6 +451,7 @@ def plan_identifier_migration_batch(
|
|||
for repository in plan.get("repositories", []):
|
||||
repositories_by_slug[str(repository.get("repo") or "")].append(repository)
|
||||
|
||||
projection_api_bases = list(projection_api_bases or [])
|
||||
batch_repositories: list[dict[str, Any]] = []
|
||||
totals = {"repositories": 0, "records": 0, "replace": 0, "assign": 0, "unchanged": 0}
|
||||
for slug in requested:
|
||||
|
|
@ -410,6 +483,12 @@ def plan_identifier_migration_batch(
|
|||
errors.append({"scope": slug, "reason": error["reason"]})
|
||||
for reason in git_preflight["errors"]:
|
||||
errors.append({"scope": slug, "reason": reason})
|
||||
projection_preflight = None
|
||||
if projection_api_bases:
|
||||
projection_preflight = _projection_migration_preflight(
|
||||
mappings, projection_api_bases
|
||||
)
|
||||
errors.extend(projection_preflight["errors"])
|
||||
|
||||
batch_repositories.append(
|
||||
{
|
||||
|
|
@ -419,8 +498,14 @@ def plan_identifier_migration_batch(
|
|||
"source_fingerprint": repository.get("source_fingerprint"),
|
||||
"source_verified": source_verification["ok"],
|
||||
"git_preflight": git_preflight,
|
||||
"projection_preflight": projection_preflight,
|
||||
"mapping_counts": {"records": len(mappings), **action_counts},
|
||||
"ready": source_verification["ok"] and git_preflight["ok"] and actionable > 0,
|
||||
"ready": (
|
||||
source_verification["ok"]
|
||||
and git_preflight["ok"]
|
||||
and actionable > 0
|
||||
and (projection_preflight is None or projection_preflight["ok"])
|
||||
),
|
||||
}
|
||||
)
|
||||
totals["repositories"] += 1
|
||||
|
|
@ -434,6 +519,7 @@ def plan_identifier_migration_batch(
|
|||
"ready_for_approval": not errors,
|
||||
"apply_authorized": False,
|
||||
"approval_required": True,
|
||||
"projection_api_bases": [base.strip().rstrip("/") for base in projection_api_bases],
|
||||
"namespace": plan.get("namespace"),
|
||||
"source_plan_sha256": plan_sha256,
|
||||
"batch_policy": "repository-atomic, sequential, stop on first failure",
|
||||
|
|
@ -484,7 +570,11 @@ def verify_identifier_migration_batch(
|
|||
fresh = None
|
||||
else:
|
||||
try:
|
||||
fresh = plan_identifier_migration_batch(plan, repo_slugs=repo_slugs)
|
||||
fresh = plan_identifier_migration_batch(
|
||||
plan,
|
||||
repo_slugs=repo_slugs,
|
||||
projection_api_bases=list(batch.get("projection_api_bases") or []),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
errors.append({"scope": "batch", "reason": str(exc)})
|
||||
fresh = None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue