zcbot/windows-node/adapters/freecad.model.author@v1/worker.py

384 lines
12 KiB
Python

"""Managed-Python launcher for the fixed FreeCAD authoring worker."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from preview_renderer import render
from recipe import RecipeError, normalize_model
ADAPTER_VERSION = "1.0.0"
FREECAD_ENV = "ZCBOT_FREECAD_CMD"
JOB_ENV = "ZCBOT_FREECAD_JOB_DIR"
MIN_VERSION = (1, 1, 3)
MAX_VERSION = (1, 2, 0)
MAX_INPUT_BYTES = 256 * 1024 * 1024
OUTPUT_MEDIA = {
"model.FCStd": ("project", "application/vnd.freecad"),
"model.step": ("geometry_step", "model/step"),
"model-preview.png": ("preview", "image/png"),
"model-recipe.json": ("model_recipe", "application/json"),
"model-manifest.json": ("model_manifest", "application/json"),
"provenance.json": ("provenance", "application/json"),
}
class AdapterError(RuntimeError):
def __init__(self, code: str, detail: str = "") -> None:
super().__init__(detail or code)
self.code = code
def _atomic_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(
value,
handle,
ensure_ascii=False,
indent=2,
sort_keys=True,
allow_nan=False,
)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _candidate_paths() -> list[Path]:
candidates: list[Path] = []
configured = os.environ.get(FREECAD_ENV)
if configured:
candidates.append(Path(configured))
discovered = shutil.which("FreeCADCmd.exe")
if discovered:
candidates.append(Path(discovered))
for variable in ("ProgramFiles", "ProgramW6432"):
root = os.environ.get(variable)
if not root:
continue
base = Path(root)
candidates.extend(
sorted(base.glob("FreeCAD 1.1*/bin/FreeCADCmd.exe"), reverse=True)
)
candidates.extend(
sorted(base.glob("FreeCAD 1.1*/FreeCADCmd.exe"), reverse=True)
)
candidates.extend(
sorted(base.glob("FreeCAD*/bin/FreeCADCmd.exe"), reverse=True)
)
result: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
if candidate.name.casefold() != "freecadcmd.exe":
continue
try:
resolved = candidate.resolve(strict=True)
except (OSError, RuntimeError):
continue
key = str(resolved).casefold()
if resolved.is_file() and key not in seen:
seen.add(key)
result.append(resolved)
return result
def _version(executable: Path) -> str:
result = subprocess.run(
[str(executable), "--version"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=7,
env=_clean_environment(),
)
output = (result.stdout + "\n" + result.stderr).strip()
match = re.search(
r"FreeCAD(?:Cmd)?\s+([0-9]+\.[0-9]+\.[0-9]+)", output, re.IGNORECASE
)
if result.returncode != 0 or match is None:
raise AdapterError("FREECAD_VERSION_PROBE_FAILED", output[:300])
lowered = output.casefold()
if any(
token in lowered
for token in (
"rc",
"weekly",
"experimental",
"development",
"preview",
"devr",
".dev",
)
) or re.search(r"\ddev(?:[.\dr]|$)", lowered):
raise AdapterError("FREECAD_PRERELEASE_UNSUPPORTED", output[:300])
version = match.group(1)
parts = tuple(int(item) for item in version.split("."))
if parts < MIN_VERSION or parts >= MAX_VERSION:
raise AdapterError("FREECAD_VERSION_UNSUPPORTED", version)
return version
def _find_freecad() -> tuple[Path, str]:
failures: list[str] = []
for candidate in _candidate_paths():
try:
return candidate, _version(candidate)
except (AdapterError, OSError, subprocess.SubprocessError) as exc:
failures.append(f"{candidate}: {exc}")
detail = "; ".join(failures) if failures else "FreeCADCmd.exe was not found"
raise AdapterError("FREECAD_NOT_AVAILABLE", detail[:500])
def _clean_environment() -> dict[str, str]:
environment = dict(os.environ)
for name in tuple(environment):
if name.casefold() in {"pythonpath", "pythonhome"}:
environment.pop(name, None)
environment["PYTHONNOUSERSITE"] = "1"
environment["PYTHONUTF8"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
return environment
def _record(job_dir: Path) -> dict[str, Any]:
value = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
if not isinstance(value, dict) or not isinstance(value.get("request"), dict):
raise AdapterError("FREECAD_JOB_REQUEST_INVALID")
return value
def _input(job_dir: Path, bindings: list[dict[str, Any]]) -> Path | None:
if not bindings:
return None
if len(bindings) != 1 or bindings[0].get("key") != "geometry":
raise AdapterError("FREECAD_INPUT_BINDING_INVALID")
input_root = (job_dir / "input").resolve(strict=True)
directory = (input_root / "geometry").resolve(strict=True)
if not directory.is_relative_to(input_root):
raise AdapterError("FREECAD_INPUT_PATH_ESCAPES_JOB")
files = [
item
for item in directory.iterdir()
if item.is_file() and not item.name.startswith(".")
]
if len(files) != 1:
raise AdapterError("FREECAD_INPUT_FILE_COUNT_INVALID")
path = files[0].resolve(strict=True)
if not path.is_relative_to(input_root) or path.suffix.casefold() not in {
".step",
".stp",
}:
raise AdapterError("FREECAD_STEP_INPUT_REQUIRED")
if path.stat().st_size > MAX_INPUT_BYTES:
raise AdapterError("FREECAD_STEP_INPUT_TOO_LARGE")
return path
def _artifact(path: Path) -> dict[str, Any]:
artifact_id, media_type = OUTPUT_MEDIA[path.name]
return {
"artifact_id": artifact_id,
"filename": path.name,
"media_type": media_type,
"size_bytes": path.stat().st_size,
"sha256": _sha256(path),
}
def _terminal(
record: dict[str, Any],
status: str,
artifacts: list[dict[str, Any]],
code: str = "",
detail: str = "",
) -> dict[str, Any]:
return {
"job_id": record.get("job_id", ""),
"lease_id": record.get("lease_id", ""),
"request_digest": record.get("request_digest", ""),
"status": status,
"artifacts": artifacts,
"error": {} if not code else {"code": code, "detail": detail[:500]},
"finished_at": datetime.now(timezone.utc).isoformat(),
}
def probe() -> int:
payload: dict[str, Any] = {
"adapter_version": ADAPTER_VERSION,
"software": "FreeCAD",
"software_version": None,
"health": "unavailable",
"detail": "",
}
try:
executable, version = _find_freecad()
payload.update(
{
"software_version": version,
"health": "ready",
"detail": f"FreeCADCmd.exe {version} detected: {executable}",
}
)
except (AdapterError, OSError, subprocess.SubprocessError) as exc:
payload["detail"] = str(exc)[:500]
print(json.dumps(payload, ensure_ascii=False))
return 0
def run(job_dir: Path) -> list[dict[str, Any]]:
job_dir = job_dir.resolve(strict=True)
record = _record(job_dir)
request = record["request"]
if request.get("outputs"):
raise AdapterError("FREECAD_OUTPUTS_MUST_BE_EMPTY")
bindings = request.get("inputs") or []
input_path = _input(job_dir, bindings)
try:
normalized = normalize_model(
request["operation"]["model"], input_path is not None
)
except RecipeError as exc:
raise AdapterError(exc.code, str(exc)) from exc
executable, software_version = _find_freecad()
output = (job_dir / "output").resolve()
output.mkdir(exist_ok=True)
if not output.is_relative_to(job_dir):
raise AdapterError("FREECAD_OUTPUT_PATH_ESCAPES_JOB")
metadata = output / ".meta"
metadata.mkdir(exist_ok=True)
_atomic_json(
metadata / "resolved-job.json",
{
"recipe": request["operation"]["model"],
"model": normalized,
"input_path": str(input_path) if input_path else None,
"software_version": software_version,
"adapter_version": ADAPTER_VERSION,
"request_digest": record.get("request_digest"),
},
)
runtime = job_dir / ".freecad-runtime"
runtime.mkdir(exist_ok=False)
user_cfg = runtime / "user.cfg"
system_cfg = runtime / "system.cfg"
user_cfg.write_text("", encoding="utf-8")
system_cfg.write_text("", encoding="utf-8")
script = Path(__file__).with_name("freecad_worker.py").resolve(strict=True)
environment = _clean_environment()
environment.update(
{
JOB_ENV: str(job_dir),
"FREECAD_USER_HOME": str(runtime / "home"),
"FREECAD_USER_DATA": str(runtime / "data"),
"FREECAD_USER_TEMP": str(runtime / "temp"),
}
)
for name in ("home", "data", "temp"):
(runtime / name).mkdir()
try:
result = subprocess.run(
[
str(executable),
"--safe-mode",
"--user-cfg",
str(user_cfg),
"--system-cfg",
str(system_cfg),
str(script),
],
check=False,
env=environment,
)
finally:
shutil.rmtree(runtime, ignore_errors=True)
engine_result_path = metadata / "engine-result.json"
if not engine_result_path.is_file():
raise AdapterError(
"FREECAD_NO_ENGINE_RESULT",
f"FreeCADCmd.exe exited with code {result.returncode}",
)
engine_result = json.loads(engine_result_path.read_text(encoding="utf-8"))
if engine_result.get("status") != "succeeded":
raise AdapterError(
str(engine_result.get("code") or "FREECAD_ENGINE_FAILED"),
str(engine_result.get("detail") or "FreeCAD engine failed"),
)
mesh_path = metadata / "preview-mesh.json"
render(mesh_path, output / "model-preview.png", normalized.get("preview"))
mesh_path.unlink(missing_ok=True)
(metadata / "resolved-job.json").unlink(missing_ok=True)
engine_result_path.unlink(missing_ok=True)
paths = [
output / "model.FCStd",
output / "model.step",
output / "model-preview.png",
output / "model-recipe.json",
output / "model-manifest.json",
metadata / "provenance.json",
]
if any(not path.is_file() or path.stat().st_size == 0 for path in paths):
raise AdapterError("FREECAD_OUTPUT_MISSING")
return [_artifact(path) for path in paths]
def main() -> int:
if sys.argv[1:] == ["--probe"]:
return probe()
if len(sys.argv) != 2:
print("[ERR] Usage: worker.py <job-directory>", file=sys.stderr)
return 2
job_dir = Path(sys.argv[1])
record: dict[str, Any] = {}
try:
record = _record(job_dir)
artifacts = run(job_dir)
_atomic_json(job_dir / "artifacts.json", artifacts)
_atomic_json(
job_dir / "terminal.json", _terminal(record, "succeeded", artifacts)
)
print("[OK] FreeCAD model authored and verified.")
return 0
except Exception as exc: # noqa: BLE001 - terminal must capture every launcher/engine failure
code = exc.code if isinstance(exc, AdapterError) else "FREECAD_ADAPTER_FAILED"
try:
_atomic_json(
job_dir / "terminal.json",
_terminal(record, "failed", [], code, str(exc)),
)
except OSError:
pass
print(f"[ERR] {code}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())