Files
FreeCADWasmDev/tools/run_web_module_smoke_once.py

482 lines
28 KiB
Python

#!/usr/bin/env python3
"""Run exactly one unattended smoke entry for each FreeCAD Web module.
The smoke run is intentionally separate from the historical scheduler ledger:
one module has one selected entry, one attempt, and one recorded result.
Missing JS/WASM glue is prepared with a targeted build before that module's
single smoke invocation; it is not retried as another smoke test.
"""
from __future__ import annotations
import json
import hashlib
import fcntl
import signal
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / "web/public/kernel"
CONFIGURE_BUILD = ROOT / "freecad-source/build-web/configure/bin"
CLOUD_BUILD = ROOT / "freecad-source/build-web/cloud-configure-r2/bin"
try:
campaign = json.loads((ROOT / "docs/current/execution-policy.json").read_text()).get("smokeRun", {}).get("directory")
except (OSError, ValueError):
campaign = None
RUN_DIR = Path(os.environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / (campaign or "docs/current/module-smoke-once")))).expanduser().resolve()
MANIFEST = RUN_DIR / "manifest.json"
TSC = ROOT / "web/node_modules/.bin/tsc"
SMOKE_TIMEOUT_SECONDS = int(os.environ.get("FREECAD_SMOKE_TIMEOUT_SECONDS", "120"))
BUILD_TIMEOUT_SECONDS = int(os.environ.get("FREECAD_SMOKE_BUILD_TIMEOUT_SECONDS", "180"))
def spec(module: str, script: str, args: list[str] | None = None, *, compile_sources: list[str] | None = None, build_tree: Path | None = None, build_target: str | None = None, runtime_probe: str | None = None) -> dict[str, object]:
return {
"module": module,
"script": script,
"args": args or [],
"compileSources": compile_sources or [],
"buildTree": str(build_tree or BUILD),
"buildTarget": build_target,
"runtimeProbe": runtime_probe,
}
SPECS = [
spec("Assembly", "tools/run_p6_assembly_action02_kernel_smoke.mjs"),
spec("BIM", "tools/run_p6_bim_runtime_smoke.mjs"),
spec("CAM", "tools/run_p6_cam_action02_kernel_smoke.mjs", ["{out}/cam-import-export-facade.js"], compile_sources=["web/src/facade-envelope.ts", "web/src/cam-import-export-facade.ts"]),
spec("Cloud", "tools/run_p6_cloud_action03_kernel_smoke.mjs", [str(CLOUD_BUILD)]),
spec("Draft", "tools/run_p6_draft_runtime_smoke.mjs"),
spec("Fem", "tools/run_p6_fem_runtime_smoke.mjs"),
spec("Help", "tools/run_p6_help_runtime_smoke.mjs"),
spec("Import", "tools/run_p6_import_runtime_smoke.mjs", [str(BUILD)]),
spec("Inspection", "tools/run_p6_inspection_action02_kernel_smoke.mjs", [str(BUILD)]),
spec("JtReader", "tools/run_p6_jtreader_kernel_smoke.mjs", [str(BUILD)]),
spec("Material", "tools/run_p6_material_runtime_smoke.mjs", [str(CONFIGURE_BUILD)], build_tree=CONFIGURE_BUILD),
spec("Measure", "tools/run_p6_measure_runtime_smoke.mjs", [str(CONFIGURE_BUILD)], build_tree=CONFIGURE_BUILD, runtime_probe="FreeCADWebMeasureRuntimeProbe"),
spec("Mesh", "tools/run_p6_mesh_runtime_smoke.mjs", [str(BUILD)]),
spec("MeshPart", "tools/run_p6_meshpart_runtime_smoke.mjs", [str(BUILD)]),
spec("OpenSCAD", "tools/run_p6_openscad_facade_smoke.mjs", [str(BUILD), "{out}"], compile_sources=["web/src/facade-envelope.ts", "web/src/openscad-inspect-facade.ts"]),
spec("Part", "tools/run_p6_part_facade_smoke.mjs", [str(BUILD), "{out}"], compile_sources=["web/src/facade-envelope.ts", "web/src/part-facade.ts"]),
spec("PartDesign", "tools/run_p6_partdesign_runtime.mjs", [str(BUILD)]),
spec("Plot", "tools/run_p6_plot_runtime_smoke.mjs", [str(BUILD)]),
spec("Points", "tools/run_p6_points_runtime.mjs", [str(BUILD)]),
spec("ReverseEngineering", "tools/run_p6_reverseengineering_runtime.mjs", [str(BUILD)]),
spec("Robot", "tools/run_p6_robot_runtime.mjs", [str(BUILD)]),
spec("Sandbox", "tools/run_p6_sandbox_action02_facade_smoke.mjs", ["{out}/sandbox-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/sandbox-facade.ts"]),
spec("Show", "tools/run_p6_show_facade.mjs", [str(BUILD)]),
spec("Sketcher", "tools/run_p6_sketcher_action02_facade_smoke.mjs", ["{out}/sketcher-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/sketcher-inspect-facade.ts"]),
spec("Spreadsheet", "tools/run_p6_spreadsheet_action02_facade_smoke.mjs", ["{out}/spreadsheet-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/spreadsheet-inspect-facade.ts"]),
spec("Start", "tools/run_start_facade_smoke.mjs", ["{out}/start-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/start-facade.ts"]),
spec("Surface", "tools/run_surface_facade_smoke.mjs", ["{out}/surface-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/surface-facade.ts"]),
spec("TechDraw", "tools/run_techdraw_inspect_facade_smoke.mjs", ["{out}/techdraw-inspect-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/techdraw-inspect-facade.ts"]),
spec("TemplatePyMod", "tools/run_templatepymod_facade_smoke.mjs", ["{out}/templatepymod-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/templatepymod-facade.ts"]),
spec("Test", "web/src/test-facade-smoke.mjs", ["{out}/test-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/test-facade.ts"]),
spec("Tux", "tools/run_tux_facade_smoke.mjs", ["{out}/tux-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/tux-facade.ts"]),
spec("Web", "tools/run_web_facade_smoke.mjs", ["{out}/web-facade.js", str(BUILD)], compile_sources=["web/src/facade-envelope.ts", "web/src/web-facade.ts"]),
]
# Current campaign uses real published WASM, not legacy source-inventory mocks.
if "comprehensive-smoke" in str(RUN_DIR):
SPECS = [dict(spec(row["module"], "tools/module-runtime-load-smoke.mjs", [row["module"]]), coverage="runtime-load") for row in SPECS]
SPECS.append(dict(spec("UI-all-workbench-panels", "tools/workbench-panels-smoke.mjs"), owner="Web", coverage="browser-panel-mount", timeout=900))
SPECS.append(dict(spec("Recent-shaft-current", "tools/shaft-current-smoke.mjs"), owner="PartDesign", coverage="current-native-shaft", timeout=120))
for owner in ("Part", "PartDesign", "Sketcher"):
ids = [owner + ":" + owner.lower() + suffix for suffix in (".createRecompute", ".saveReopen")]
SPECS.append(dict(spec("Core-" + owner, "tools/run_business_acceptance.py",
["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner,
coverage="core-native-model-and-FCStd", timeout=240))
SPECS.append(dict(spec("Repaired-browser-workers", "tools/repaired-workers-browser-smoke.mjs"), owner="Web", additionalOwners=["Help", "Plot", "Show"], coverage="browser-native-request", timeout=240))
groups = {
"UI-shell-files": ["product:document-workspace", "product:react-primary-shell", "product:model-tree-properties", "product:multi-object-viewport", "product:desktop-command-ui", "product:task-panel", "product:workbench-navigation", "product:dynamic-module-session", "product:file-workflows", "product:recovery-status"],
"UI-desktop-workspace": ["product:desktop-workspace-parity"],
"UI-task-interactions": ["product:desktop-task-interaction-parity"],
"UI-cross-workbench": ["product:cross-workbench-document-editing"],
"Recent-shaft": ["PartDesign:desktop:PartDesign_WizardShaft", "PartDesign:desktop:PartDesign_WizardShaftCallBack"],
}
for name, ids in groups.items():
owner = "PartDesign" if name == "Recent-shaft" else "Web"
SPECS.append(dict(spec(name, "tools/run_business_acceptance.py", ["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner, coverage="business-browser", timeout=600))
for contract_path in sorted((ROOT / "docs/business-acceptance").glob("*.json")):
owner = contract_path.stem
if owner == "Web":
continue
contract = json.loads(contract_path.read_text())
ids = contract.get("coveredIds") or contract.get("selectedIds") or []
ids = [key for key in ids if key not in groups["Recent-shaft"]]
if ids:
SPECS.append(dict(spec("Business-" + owner, "tools/run_business_acceptance.py",
["--module", owner, "--ids", *ids, "--reuse", "--validate-deferred"]), owner=owner,
coverage="declared-business-cases", timeout=600))
for row in SPECS:
if row["module"] in {"Business-Part", "Business-PartDesign", "Business-Sketcher"}:
row["script"] = "tools/run_smoke_business_records.py"
row["args"] = [value for value in row["args"] if value not in {"--reuse", "--validate-deferred"}]
row["timeout"] = 18000
row["coverage"] = "declared-business-cases-with-per-ID-checkpoints"
web_contract = json.loads((ROOT / "docs/business-acceptance/Web.json").read_text())
covered_web_ids = {key for row in SPECS if row.get("owner") == "Web" for key in row.get("args", [])}
remaining_web_ids = [key for key in web_contract.get("coveredIds", []) if key not in covered_web_ids]
if remaining_web_ids:
# Several command IDs share the same existing manual workflow. The
# Web runner deduplicates those workflows when submitted together.
SPECS.append(dict(spec("UI-declared-desktop-commands", "tools/run_business_acceptance.py",
["--module", "Web", "--ids", *remaining_web_ids, "--reuse", "--validate-deferred"]), owner="Web",
coverage="declared-desktop-command-workflows", timeout=600))
def inputs_for(row):
from business_acceptance import digest, module_inputs
owner = row.get("owner", row["module"])
registry = json.loads((ROOT / "web/public/modules/manifest.json").read_text())
paths = {ROOT / str(row["script"]), ROOT / "tools/wasm-probe-runtime.mjs"}
for dependency_owner in [owner, *row.get("additionalOwners", [])]:
sources = module_inputs(dependency_owner)
paths.update(ROOT / name for name in sources["sources"])
for worker in registry["modules"].get(dependency_owner, {}).get("workers", {}).values():
paths.update(ROOT / "web/public" / value.lstrip("/") for key, value in worker.items() if key.endswith("Url") and isinstance(value, str))
contract_path = ROOT / "docs/business-acceptance" / (owner + ".json")
if row.get("owner") and contract_path.is_file():
contract = json.loads(contract_path.read_text())
command = contract.get("command", [])
if len(command) > 1:
paths.add(ROOT / command[1])
for name in contract.get("dependencies", []) + contract.get("caseDependencies", []):
dependency = ROOT / name
if dependency.is_dir():
paths.update(p for p in dependency.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
else:
paths.add(dependency)
# Track imported helper code recursively, without loading it into a runtime.
todo = list(paths)
while todo:
current = todo.pop()
if current.suffix not in {".mjs", ".ts", ".tsx", ".py"} or not current.is_file():
continue
for relative in re.findall(r"(?:from\s*|import\s*)[\"'](\.[^\"']+)[\"']", current.read_text()):
child = (current.parent / relative).resolve()
if not child.is_file() and child.suffix == ".js":
child = child.with_suffix(".ts")
if child.is_relative_to(ROOT) and child.is_file() and child not in paths:
paths.add(child); todo.append(child)
if row.get("owner"):
paths.add(ROOT / "docs/business-acceptance" / (owner + ".json"))
paths.update((ROOT / "web/dist/assets").glob("*"))
paths.update(p for p in (ROOT / "web/dist").glob("*.html") if p.name != "execution.html")
if row.get("owner") == "Web":
# web-acceptance-cases launches the manual runner only for IDs in
# manual-business-mapping; other UI groups never execute that branch.
mapping_path = ROOT / "tools/manual-business-mapping.json"
mapping = json.loads(mapping_path.read_text())["records"]
paths.add(mapping_path)
if not any(key in mapping for key in row.get("args", [])):
paths.discard(ROOT / "tools/run_user_manual_tests.mjs")
paths.discard(ROOT / "tools/user-manual-remaining-cases.mjs")
files = {str(p.relative_to(ROOT)): digest(p) for p in sorted(paths) if p.is_file()}
return hashlib.sha256(json.dumps({"spec":row,"files":files},sort_keys=True).encode()).hexdigest(), files
def reusable_inputs(previous, row, fingerprint, inputs):
if not previous:
return False
if previous.get("fingerprint") == fingerprint:
return True
old = previous.get("inputs", {})
# A dependency-scope correction may remove unrelated files. Reuse only
# when every retained input was already measured with the same contents
# and the old hash proves the identical executable specification.
old_key = hashlib.sha256(json.dumps({"spec": row, "files": old}, sort_keys=True).encode()).hexdigest()
return bool(inputs) and old_key == previous.get("fingerprint") and all(old.get(p) == value for p, value in inputs.items())
def run_isolated(command, timeout):
# Own and reap the entire process group, including browser/worker children.
process = subprocess.Popen(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
try:
stdout, stderr = process.communicate(timeout=timeout)
return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
finally:
# Let a Python business runner reap its own isolated case/browser
# group before enforcing termination of this wrapper group.
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
except (ProcessLookupError, subprocess.TimeoutExpired):
pass
try: os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError: pass
process.wait()
def load_manifest() -> dict[str, object]:
if MANIFEST.exists():
try:
value = json.loads(MANIFEST.read_text(encoding="utf-8"))
if isinstance(value, dict) and value.get("schema") == "freecad-web-module-smoke-once/v1":
return value
except json.JSONDecodeError:
pass
return {
"schema": "freecad-web-module-smoke-once/v1",
"startedAt": None,
"status": "planned",
"oneSmokePerModule": True,
"policy": {
"preflightBuildAllowed": True,
"automaticRetry": False,
"fixOnFailure": True,
},
"plan": [dict(row) for row in SPECS],
"modules": [row["module"] for row in SPECS],
"results": {},
}
def save_manifest(manifest: dict[str, object]) -> None:
RUN_DIR.mkdir(parents=True, exist_ok=True)
temporary = MANIFEST.with_suffix(".tmp")
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(MANIFEST)
if "comprehensive-smoke" in str(RUN_DIR):
results = manifest.get("results", {})
payload = {"schema": "freecad-web-smoke-progress/v1", "status": manifest.get("status"),
"updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"total": len(manifest.get("modules", [])), "completed": len(results),
"passed": sum(row.get("status") == "pass" for row in results.values()),
"current": manifest.get("current"),
"failed": [name for name, row in results.items() if row.get("status") != "pass"]}
public = ROOT / "web/public/smoke-status.json"
staging = public.with_suffix(".tmp")
staging.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
staging.replace(public)
def compile_facade(sources: list[str], out: Path) -> tuple[int, str, str]:
if not sources:
return 0, "", ""
if not TSC.exists():
return 127, "", f"missing TypeScript compiler: {TSC}"
out.mkdir(parents=True, exist_ok=True)
command = [
str(TSC), "--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler",
"--strict", "--skipLibCheck", "--outDir", str(out), "--declaration", "false", "--noEmit", "false",
*[str(ROOT / source) for source in sources],
]
run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=180)
return run.returncode, run.stdout, run.stderr
def prepare_build(row: dict[str, object]) -> tuple[int, str, str]:
target = row.get("buildTarget")
if not target:
return 0, "", ""
tree = Path(str(row["buildTree"]))
js_candidates = list(tree.glob(f"{target}.js"))
wasm_candidates = list(tree.glob(f"{target}.wasm"))
if js_candidates and wasm_candidates:
return 0, "artifact already present\n", ""
command = ["cmake", "--build", str(tree.parent), "--target", str(target), f"-j{os.cpu_count() or 1}"]
run = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, timeout=BUILD_TIMEOUT_SECONDS)
return run.returncode, run.stdout, run.stderr
def stage_runtime_build(row: dict[str, object]) -> Path | None:
"""Make pthread workers CommonJS-compatible without modifying source artifacts."""
probe = row.get("runtimeProbe")
if not probe:
return None
build = Path(str(row["buildTree"]))
runtime = build / f"{probe}.js"
worker = build / f"{probe}.worker.js"
wasm = build / f"{probe}.wasm"
if not (runtime.exists() and worker.exists() and wasm.exists()):
return None
stage = Path(tempfile.mkdtemp(prefix=f"freecad-smoke-stage-{str(row['module']).lower()}-"))
runtime_text = runtime.read_text(encoding="utf-8")
runtime_text = runtime_text.replace(f"{probe}.worker.js", f"{probe}.worker.cjs")
(stage / f"{probe}.js").write_text(runtime_text, encoding="utf-8")
(stage / f"{probe}.worker.cjs").write_text(worker.read_text(encoding="utf-8"), encoding="utf-8")
shutil.copy2(wasm, stage / wasm.name)
for suffix in (".data",):
optional = build / f"{probe}{suffix}"
if optional.exists():
shutil.copy2(optional, stage / optional.name)
return stage
def module_command(row: dict[str, object], out: Path | None, build_override: Path | None = None) -> list[str]:
original_build = str(row["buildTree"])
args = [str(value).replace("{out}", str(out or "")) for value in row.get("args", [])]
if build_override is not None:
args = [value.replace(original_build, str(build_override)) for value in args]
return [sys.executable if str(row["script"]).endswith(".py") else "node", str(ROOT / str(row["script"])), *args]
def result_status(returncode: int, stdout: str) -> str:
if returncode != 0:
return "fail"
matches = re.findall(r'"(?:result|status)"\s*:\s*"(pass|passed|fail|failed|blocked)"', stdout)
if any(value in {"fail", "failed", "blocked"} for value in matches):
return "fail"
return "pass"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--only", nargs="+", help="Resume only named checks; other results remain recorded")
parser.add_argument("--collect-business-records", action="store_true",
help="Audit cumulative Part/PartDesign/Sketcher ID evidence after targeted repair; does not recertify historical inputs")
options = parser.parse_args()
if options.collect_business_records and (not options.only or set(options.only) - {"Business-Part", "Business-PartDesign", "Business-Sketcher"}):
parser.error("--collect-business-records requires --only with the checkpointed business groups")
def interrupted(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
if options.only and set(options.only) - {row["module"] for row in SPECS}:
parser.error("Unknown smoke check name")
RUN_DIR.mkdir(parents=True, exist_ok=True)
lock = (RUN_DIR / "run.lock").open("a")
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Smoke campaign already running"); return 2
manifest = load_manifest()
# Keep the executable plan visible even when resuming an older manifest.
manifest.setdefault("oneSmokePerModule", True)
manifest.setdefault("policy", {
"preflightBuildAllowed": True,
"automaticRetry": False,
"fixOnFailure": True,
})
manifest["plan"] = [dict(row) for row in SPECS]
manifest["modules"] = [row["module"] for row in SPECS]
if manifest.get("status") not in {"completed", "completed_with_failures"}:
manifest["status"] = "running"
if manifest.get("startedAt") is None:
manifest["startedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
manifest["status"] = "running"
save_manifest(manifest)
results = manifest.setdefault("results", {})
assert isinstance(results, dict)
overall = 0
manifest["status"] = "running"
manifest["pid"] = os.getpid()
manifest.pop("completedAt", None)
for row in SPECS:
module = str(row["module"])
if options.only and module not in options.only:
continue
fingerprint, inputs = inputs_for(row)
record_digest = None
if options.collect_business_records:
from business_acceptance import digest
record_path = RUN_DIR / ("records-" + str(row["owner"]) + ".json")
record_digest = digest(record_path)
previous = results.get(module)
if reusable_inputs(previous, row, fingerprint, inputs) and (not options.collect_business_records or previous.get("recordFingerprint") == record_digest):
print(json.dumps({"module":module,"status":"reused","originalStatus":previous["status"]}),flush=True)
continue
if previous:
manifest.setdefault("history", []).append(previous)
started = time.time()
result: dict[str, object] = {"module": module, "script": row["script"], "attempt": 1, "status": "preparing", "fingerprint": fingerprint, "inputs": inputs, "coverage": row.get("coverage", "legacy-smoke")}
if options.collect_business_records:
result["recordFingerprint"] = record_digest
manifest["current"] = {"module": module, "phase": "preparing", "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "startedEpoch": started}
save_manifest(manifest)
out: Path | None = None
if row.get("compileSources"):
manifest["current"]["phase"] = "compile" # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
out = Path(tempfile.mkdtemp(prefix=f"freecad-smoke-{module.lower()}-"))
result["compileSources"] = row["compileSources"]
code, stdout, stderr = compile_facade(list(row["compileSources"]), out)
result["compileExitCode"] = code
result["compileDurationSeconds"] = round(time.time() - phase_started, 3)
if code != 0:
result.update(status="fail", phase="compile", stdout=stdout[-4000:], stderr=stderr[-4000:])
results[module] = result; manifest.pop("current", None); save_manifest(manifest); overall = 1
print(json.dumps({"module": module, "status": result["status"], "phase": "compile", "durationSeconds": result.get("compileDurationSeconds")}, ensure_ascii=False), flush=True)
continue
manifest["current"]["phase"] = "build" # type: ignore[index]
manifest["current"]["timeoutSeconds"] = BUILD_TIMEOUT_SECONDS # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
code, stdout, stderr = prepare_build(row)
result["buildExitCode"] = code
result["buildDurationSeconds"] = round(time.time() - phase_started, 3)
if code != 0:
result.update(status="fail", phase="build", stdout=stdout[-4000:], stderr=stderr[-4000:])
results[module] = result; manifest.pop("current", None); save_manifest(manifest); overall = 1
print(json.dumps({"module": module, "status": result["status"], "phase": "build", "durationSeconds": result.get("buildDurationSeconds")}, ensure_ascii=False), flush=True)
continue
staged_build = stage_runtime_build(row)
if staged_build is not None:
result["stagedBuild"] = str(staged_build)
command = module_command(row, out, staged_build)
if options.collect_business_records:
command.append("--summarize-only")
result["validationBasis"] = "Cumulative original-ID evidence plus targeted repairs; original per-ID fingerprints and execution dates retained"
result["recordSummary"] = "summary-" + str(row["owner"]) + ".json"
result["command"] = command
manifest["current"]["phase"] = "smoke" # type: ignore[index]
manifest["current"]["timeoutSeconds"] = row.get("timeout", SMOKE_TIMEOUT_SECONDS) # type: ignore[index]
save_manifest(manifest)
phase_started = time.time()
try:
run = run_isolated(command, row.get("timeout", SMOKE_TIMEOUT_SECONDS))
(RUN_DIR / (module + ".stdout.log")).write_text(run.stdout)
(RUN_DIR / (module + ".stderr.log")).write_text(run.stderr)
log_stem = module + "-" + fingerprint[:16]
(RUN_DIR / (log_stem + ".stdout.log")).write_text(run.stdout)
(RUN_DIR / (log_stem + ".stderr.log")).write_text(run.stderr)
result["logs"] = [log_stem + ".stdout.log", log_stem + ".stderr.log"]
status = result_status(run.returncode, run.stdout)
result["smokeDurationSeconds"] = round(time.time() - phase_started, 3)
result.update(status=status, phase="smoke", exitCode=run.returncode, stdout=run.stdout[-8000:], stderr=run.stderr[-8000:], durationSeconds=round(time.time() - started, 3))
nested_timeout = re.search(r"timed out after (\d+) seconds", run.stdout)
if nested_timeout:
result["timeoutSeconds"] = int(nested_timeout.group(1))
result["timeoutOwner"] = "business-case-runner"
if status != "pass": overall = 1
except subprocess.TimeoutExpired as error:
result["smokeDurationSeconds"] = round(time.time() - phase_started, 3)
result.update(status="fail", phase="smoke", exitCode=None, stdout=str(error.stdout or "")[-8000:], stderr=str(error.stderr or "")[-8000:], timeoutSeconds=SMOKE_TIMEOUT_SECONDS, durationSeconds=round(time.time() - started, 3)); overall = 1
results[module] = result
manifest.pop("current", None)
save_manifest(manifest)
print(json.dumps({"module": module, "status": result["status"], "durationSeconds": result.get("durationSeconds")}, ensure_ascii=False), flush=True)
failed = [name for name, result in results.items() if isinstance(result, dict) and result.get("status") != "pass"]
manifest["status"] = "paused" if any(row["module"] not in results for row in SPECS) else "completed_with_failures" if failed else "completed"
manifest.pop("current", None)
manifest["failedModules"] = failed
manifest["completedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
save_manifest(manifest)
print(json.dumps({"status": manifest["status"], "modules": len(results), "failedModules": failed}, ensure_ascii=False))
return 1 if failed else 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
checkpoint = load_manifest()
checkpoint["status"] = "interrupted"
save_manifest(checkpoint)
raise SystemExit(130)