#!/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())