Files
FreeCADWasmDev/tools/flush_deferred_module_builds.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

224 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Flush source-ready feature builds without reopening a Codex turn.
Deferred feature batches are intentionally coalesced while a module family is
being edited. Once the planner has no work left, this small native-only step
closes that transaction: it builds each queued target, syncs artifacts once,
and records the original batch receipts. Failed targets stay queued with a
bounded retry time so an idle scheduler cannot spin on the same linker error.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from implementation_first import record_implementation_batch
ROOT = Path(__file__).resolve().parents[1]
PENDING_PATH = ROOT / "docs/current/module-build-pending.json"
OUTPUT_PATH = ROOT / "docs/current/module-batch-pipeline.json"
JOBS = max(1, int(os.environ.get("FREECAD_BUILD_JOBS", "8")))
RETRY_SECONDS = max(30, int(os.environ.get("FREECAD_DEFERRED_BUILD_RETRY_SECONDS", "300")))
def load_state() -> dict[str, object]:
try:
value = json.loads(PENDING_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
value = {}
if not isinstance(value, dict) or value.get("schema") != "freecad-web-module-build-pending/v1":
return {"schema": "freecad-web-module-build-pending/v1", "targets": {}}
value.setdefault("targets", {})
return value
def save_state(state: dict[str, object]) -> None:
state["schema"] = "freecad-web-module-build-pending/v1"
state["updatedAt"] = datetime.now(timezone.utc).isoformat()
PENDING_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = PENDING_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(PENDING_PATH)
def run(command: list[str], timeout: int) -> dict[str, object]:
started = time.monotonic()
environment = {key: str(value) for key, value in os.environ.items()}
environment.update({
"EM_CONFIG": str(ROOT / "tools/emscripten-writable-config.py"),
"EMCC_CORES": str(JOBS),
"CMAKE_BUILD_PARALLEL_LEVEL": str(JOBS),
"FREECAD_WEB_FAST_LINK": "ON",
})
try:
result = subprocess.run(command, cwd=ROOT, env=environment, text=True, capture_output=True, timeout=timeout, check=False)
return {
"command": command,
"seconds": round(time.monotonic() - started, 3),
"exitCode": result.returncode,
"stdout": (result.stdout or "")[-3000:],
"stderr": (result.stderr or "")[-3000:],
}
except subprocess.TimeoutExpired as error:
return {
"command": command,
"seconds": round(time.monotonic() - started, 3),
"exitCode": 124,
"stdout": str(error.stdout or "")[-1000:],
"stderr": str(error.stderr or "timeout")[-3000:],
}
def save_report(payload: dict[str, object]) -> None:
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = OUTPUT_PATH.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(OUTPUT_PATH)
def main(argv: list[str] | None = None) -> int:
global OUTPUT_PATH
parser = argparse.ArgumentParser()
parser.add_argument("--json", action="store_true", help="emit the compact JSON summary (default output)")
parser.add_argument("--force", action="store_true", help="ignore retry backoff after a source-side fix")
parser.add_argument("--exclude-target", action="append", default=[], help="keep this tree:target queued for the next coalesced batch")
parser.add_argument("--report-path", type=Path, help="write a separate flush report without replacing current batch diagnostics")
arguments = parser.parse_args(argv)
if arguments.report_path:
OUTPUT_PATH = ROOT / arguments.report_path
state = load_state()
targets = state.get("targets") if isinstance(state.get("targets"), dict) else {}
if not targets:
payload = {"status": "no_pending_builds", "targets": 0, "totalSeconds": 0.0}
save_report(payload)
print(json.dumps(payload, ensure_ascii=False))
return 0
now = time.time()
results: list[dict[str, object]] = []
flushed = 0
skipped = 0
excluded = 0
failed = 0
changed = False
built: list[dict[str, object]] = []
for cache_key in sorted(list(targets)):
if cache_key in arguments.exclude_target:
excluded += 1
continue
pending = targets.get(cache_key)
if not isinstance(pending, list) or not pending:
targets.pop(cache_key, None)
changed = True
continue
metadata = pending[0] if isinstance(pending[0], dict) else {}
next_attempt = float(metadata.get("nextAttemptAt", 0) or 0)
if next_attempt > now and not arguments.force:
skipped += 1
results.append({"cacheKey": cache_key, "status": "retry_backoff", "retryAt": next_attempt})
continue
tree_value, separator, target = cache_key.rpartition(":")
tree = ROOT / tree_value
if not separator or not target or not tree.is_dir():
failed += 1
results.append({"cacheKey": cache_key, "status": "invalid_target", "tree": tree_value, "target": target})
continue
preflight = run([sys.executable, "tools/check_web_link_closure.py", tree_value, target, "--json"], 60)
if preflight["exitCode"] != 0:
attempt = dict(metadata)
attempt["lastAttemptAt"] = datetime.now(timezone.utc).isoformat()
attempt["nextAttemptAt"] = now + RETRY_SECONDS
attempt["lastError"] = "link-closure-preflight"
targets[cache_key] = [attempt, *pending[1:]]
changed = True
failed += 1
results.append({"cacheKey": cache_key, "status": "preflight_failed", "preflight": preflight})
continue
build = run(["cmake", "--build", tree_value, "--target", target, f"-j{JOBS}"], 1800)
runtime = tree / "bin" / f"{target}.js"
wasm = tree / "bin" / f"{target}.wasm"
if build["exitCode"] != 0 or not runtime.is_file() or not wasm.is_file():
attempt = dict(metadata)
attempt["lastAttemptAt"] = datetime.now(timezone.utc).isoformat()
attempt["nextAttemptAt"] = now + RETRY_SECONDS
attempt["lastError"] = "build" if build["exitCode"] != 0 else "artifact-missing"
targets[cache_key] = [attempt, *pending[1:]]
changed = True
failed += 1
results.append({"cacheKey": cache_key, "status": "build_failed", "build": build})
continue
built.append({
"cacheKey": cache_key,
"pending": pending,
"target": target,
"tree": tree_value,
"artifacts": [str(path.relative_to(ROOT)) for path in (runtime, wasm)],
"build": build,
})
if built:
sync = run(["node", "web/tools/sync-assets.mjs"], 300)
else:
sync = None
if sync is not None and sync["exitCode"] != 0:
failed += len(built)
for built_target in built:
cache_key = str(built_target["cacheKey"])
pending = built_target["pending"] if isinstance(built_target["pending"], list) else []
metadata = dict(pending[0]) if pending and isinstance(pending[0], dict) else {}
metadata.update({
"lastAttemptAt": datetime.now(timezone.utc).isoformat(),
"nextAttemptAt": now + RETRY_SECONDS,
"lastError": "artifact-sync",
})
targets[cache_key] = [metadata, *pending[1:]]
results.append({"cacheKey": cache_key, "status": "artifact_sync_failed", "build": built_target["build"]})
changed = True
else:
for built_target in built:
cache_key = str(built_target["cacheKey"])
pending = built_target["pending"] if isinstance(built_target["pending"], list) else []
for batch in pending:
if not isinstance(batch, dict):
continue
record_implementation_batch(
batch,
target=str(built_target["target"]),
build_tree=str(built_target["tree"]),
artifacts=list(built_target["artifacts"]),
source="deferred-build-flush",
)
targets.pop(cache_key, None)
changed = True
flushed += len(pending)
results.append({"cacheKey": cache_key, "status": "flushed", "batchCount": len(pending), "build": built_target["build"]})
state["targets"] = targets
if changed:
save_state(state)
audit = run([sys.executable, "tools/generate_web_business_completion_audit.py"], 60) if flushed else None
status = "flushed" if flushed and not failed else "partial" if flushed else "failed" if failed else "coalescing" if excluded and not skipped else "retry_backoff"
payload = {
"schema": "freecad-web-deferred-build-flush/v1",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"status": status,
"flushedBatches": flushed,
"skippedTargets": skipped,
"excludedTargets": excluded,
"failedTargets": failed,
"sync": sync,
"businessAudit": audit,
"results": results,
}
save_report(payload)
print(json.dumps({"status": status, "flushedBatches": flushed, "skippedTargets": skipped, "failedTargets": failed}, ensure_ascii=False))
return 0 if not failed else 1
if __name__ == "__main__":
raise SystemExit(main())