Files
FreeCADWasmDev/tools/preflight_current_module_batch.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

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())