147 lines
7.3 KiB
Python
147 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Cheap, read-only preflight for the active module batch.
|
|
|
|
This checks only materialized state and artifact metadata. It deliberately
|
|
does not scan the source tree or execute a native/WASM gate.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from scheduler_progress import batch_retry_details
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
STATE = ROOT / "docs/current/task-state.json"
|
|
CHECKPOINT = ROOT / "docs/current/continuation-state.json"
|
|
BATCH = ROOT / "docs/current/module-batch-manifest.json"
|
|
CONTEXT_DIR = ROOT / "docs/current/context"
|
|
LEGACY_TIERS = {"tier-1-readonly", "tier-2-pure", "tier-3-stateful", "tier-4-restricted", "object-contract"}
|
|
FEATURE_SCHEMA = "freecad-web-feature-capability-batch/v1"
|
|
BUSINESS_STRATEGIES = {"module-function-direct-deferred-validation", "module-business-first-deferred-validation"}
|
|
|
|
|
|
def load(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def is_feature_batch(batch: dict) -> bool:
|
|
return batch.get("schema") == FEATURE_SCHEMA or batch.get("strategy") == "feature-first-deferred-validation"
|
|
|
|
|
|
def is_business_batch(batch: dict) -> bool:
|
|
return batch.get("strategy") in BUSINESS_STRATEGIES
|
|
|
|
|
|
def batch_identity_parts(batch: dict, module: str, tier: str, family: str, ids: list[str]) -> list[str]:
|
|
# Keep this aligned with prepare_feature_capability_batch.py. Legacy API
|
|
# batches include tier in their identity; feature batches deliberately do
|
|
# not because their tier is the constant scheduling marker.
|
|
if is_feature_batch(batch):
|
|
return [module, family, *ids]
|
|
return [module, tier, family, *ids]
|
|
|
|
|
|
def record_belongs_to_batch(batch: dict, module: str, record_id: str) -> bool:
|
|
if batch.get("selected", {}).get("tier") == "failure-reconciliation":
|
|
return record_id.startswith(f"failure:{module}:")
|
|
if batch.get("selected", {}).get("tier") == "business-acceptance":
|
|
return record_id.startswith(f"{module}:") or record_id.startswith(f"business-format:{module}:") or (module == "Web" and record_id.startswith("product:"))
|
|
if is_feature_batch(batch):
|
|
return record_id.startswith(f"{module}:") or (module == "Web"
|
|
and batch.get("selected", {}).get("family") == "runtime-issue" and record_id.startswith("product:"))
|
|
if is_business_batch(batch):
|
|
return record_id.startswith(f"business-format:{module}:") or (module == "Web" and record_id.startswith("product:"))
|
|
return record_id.startswith(f"py:{module}:") or record_id.startswith(f"obj:{module}:")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
try:
|
|
state = load(STATE)
|
|
checkpoint = load(CHECKPOINT)
|
|
batch = load(BATCH)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
result = {"schema": "freecad-web-current-batch-preflight/v1", "ok": False, "errors": [f"state read failed: {exc}"], "warnings": []}
|
|
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 1
|
|
|
|
active = state.get("active_task")
|
|
selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {}
|
|
records = batch.get("records") if isinstance(batch.get("records"), list) else []
|
|
cooling = batch_retry_details(batch)
|
|
if cooling:
|
|
errors.append(f"selected business records are cooling down until {cooling['retryAt']}; replan before execution")
|
|
if not isinstance(active, str) or not active:
|
|
warnings.append("no active task")
|
|
if batch.get("status") != "ready":
|
|
errors.append(f"batch status is {batch.get('status', 'missing')}")
|
|
if batch.get("taskId") not in (None, active):
|
|
errors.append("batch taskId does not match active task")
|
|
if not selected or not selected.get("module") or not selected.get("tier") or not selected.get("family"):
|
|
errors.append("selected module/tier/family is incomplete")
|
|
module = selected.get("module") if isinstance(selected.get("module"), str) else ""
|
|
tier = selected.get("tier") if isinstance(selected.get("tier"), str) else ""
|
|
family = selected.get("family") if isinstance(selected.get("family"), str) else ""
|
|
allowed_tiers = LEGACY_TIERS | ({"feature-workflow"} if is_feature_batch(batch) else set()) | ({"format-workflow", "product-integration", "business-acceptance", "failure-reconciliation"} if is_business_batch(batch) else set())
|
|
if tier not in allowed_tiers:
|
|
errors.append(f"unsupported batch tier: {tier or 'missing'}")
|
|
ids: list[str] = []
|
|
for row in records:
|
|
if not isinstance(row, list) or len(row) < 2:
|
|
errors.append("batch contains a malformed compact record")
|
|
continue
|
|
record_id = row[0]
|
|
if not isinstance(record_id, str) or not record_id:
|
|
errors.append("batch contains a record without an id")
|
|
continue
|
|
ids.append(record_id)
|
|
if module and not record_belongs_to_batch(batch, module, record_id):
|
|
errors.append(f"record crosses module boundary: {record_id}")
|
|
source_path = row[3] if len(row) > 3 else None
|
|
if isinstance(source_path, str) and source_path:
|
|
source_file = ROOT / source_path
|
|
if not source_file.is_file():
|
|
source_file = ROOT / "freecad-source" / source_path
|
|
if not source_file.is_file():
|
|
warnings.append(f"source path not present in checkout: {source_path}")
|
|
if len(ids) != len(set(ids)):
|
|
errors.append("batch contains duplicate record ids")
|
|
identity_parts = batch_identity_parts(batch, module, tier, family, ids)
|
|
expected_batch_id = hashlib.sha256("|".join(identity_parts).encode("utf-8")).hexdigest()[:24]
|
|
if batch.get("batchId") and batch.get("batchId") != expected_batch_id:
|
|
errors.append("batchId does not match selected module/tier/family and record ids")
|
|
selected_count = int(selected.get("recordCount", len(records)) or 0)
|
|
if selected_count != len(records):
|
|
errors.append("selected.recordCount does not match records length")
|
|
minimum = int(batch.get("minimumRecords") or (checkpoint.get("batchPolicy") or {}).get("minRecords", 2) or 2)
|
|
if len(records) < minimum and batch.get("singletonFallback") is not True:
|
|
errors.append(f"batch has {len(records)} records below minimum {minimum} without singletonFallback")
|
|
context_path = CONTEXT_DIR / f"{active}.json" if isinstance(active, str) and active else None
|
|
if context_path is None or not context_path.is_file():
|
|
errors.append("active context packet is missing")
|
|
result = {
|
|
"schema": "freecad-web-current-batch-preflight/v1",
|
|
"ok": not errors,
|
|
"activeTask": active,
|
|
"batchId": batch.get("batchId"),
|
|
"module": module,
|
|
"tier": tier,
|
|
"family": family,
|
|
"records": len(records),
|
|
"singletonFallback": batch.get("singletonFallback") is True,
|
|
"errors": errors,
|
|
"warnings": warnings,
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0 if not errors else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|