Files
2026-03-15 21:56:51 +01:00

385 lines
12 KiB
Python

#!/usr/bin/env python3
"""Shared helpers for local CAD runtime wrappers and MCP servers."""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CAD_STACK = ROOT / "tools" / "hw" / "cad_stack.sh"
CAD_HOME = ROOT / ".cad-home"
FREECAD_POOL_SOCKET = CAD_HOME / "freecad_pool.sock"
FREECAD_POOL_PID = CAD_HOME / "freecad_pool.pid"
FREECAD_POOL_LOG = CAD_HOME / "freecad_pool.log"
FREECAD_POOL_DAEMON = CAD_HOME / "freecad_pool_daemon.py"
FREECAD_POOL_SOCKET_REL = ".cad-home/freecad_pool.sock"
class CadRuntimeError(RuntimeError):
"""Raised when a CAD runtime command fails or returns invalid output."""
def ensure_cad_home() -> Path:
CAD_HOME.mkdir(parents=True, exist_ok=True)
return CAD_HOME
def create_runtime_temp_dir(prefix: str) -> Path:
ensure_cad_home()
path = Path(tempfile.mkdtemp(prefix=f"{prefix}-", dir=CAD_HOME))
os.chmod(path, 0o700)
return path
def cleanup_runtime_path(path: Path | None) -> None:
if path is None:
return
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.exists():
path.unlink()
except Exception:
pass
def coerce_workspace_path(raw_path: str | Path, *, suffix: str | None = None) -> Path:
raw = Path(str(raw_path)).expanduser()
if raw.is_absolute():
resolved = raw.resolve()
else:
resolved = (ROOT / raw).resolve()
try:
resolved.relative_to(ROOT)
except ValueError as exc:
raise CadRuntimeError(f"path escapes Kill_LIFE workspace: {raw_path}") from exc
if suffix and resolved.suffix.lower() != suffix.lower():
raise CadRuntimeError(f"path must end with {suffix}: {raw_path}")
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def last_nonempty_line(text: str) -> str:
for line in reversed(text.splitlines()):
candidate = line.strip()
if candidate:
return candidate
return ""
def parse_json_tail(text: str) -> dict[str, Any]:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines:
raise CadRuntimeError("expected JSON output, got empty stdout")
for line in reversed(lines):
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict):
return payload
raise CadRuntimeError(f"expected JSON object, got: {payload!r}")
raise CadRuntimeError(f"expected JSON output, got: {last_nonempty_line(text)}")
def run_cad_stack(
command: str,
*args: str,
timeout: float = 120.0,
env: dict[str, str] | None = None,
clean_env: bool = False,
) -> subprocess.CompletedProcess[str]:
cmd = ["bash", str(CAD_STACK), command, *args]
if clean_env:
merged_env = {}
for key in ("PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_CTYPE", "USER", "LOGNAME", "SHELL"):
value = os.environ.get(key)
if value:
merged_env[key] = value
else:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
return subprocess.run(
cmd,
cwd=ROOT,
env=merged_env,
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
def run_cad_stack_background(
command: str,
*args: str,
env: dict[str, str] | None = None,
log_path: Path | None = None,
) -> subprocess.Popen[str]:
cmd = ["bash", str(CAD_STACK), command, *args]
merged_env = os.environ.copy()
if env:
merged_env.update(env)
stdout_target: Any = subprocess.DEVNULL
stderr_target: Any = subprocess.DEVNULL
log_handle = None
if log_path is not None:
log_path.parent.mkdir(parents=True, exist_ok=True)
log_handle = open(log_path, "a", encoding="utf-8")
stdout_target = log_handle
stderr_target = log_handle
proc = subprocess.Popen(
cmd,
cwd=ROOT,
env=merged_env,
stdin=subprocess.DEVNULL,
stdout=stdout_target,
stderr=stderr_target,
text=True,
)
if log_handle is not None:
log_handle.close()
return proc
def _freecad_pool_enabled() -> bool:
return str(os.getenv("FREECAD_POOL_ENABLED", "1")).strip().lower() not in {"0", "false", "no"}
def _freecad_pool_start_timeout() -> float:
raw = str(os.getenv("FREECAD_POOL_START_TIMEOUT", "8")).strip()
try:
value = float(raw)
except ValueError:
value = 8.0
return max(1.0, min(value, 30.0))
def _freecad_pool_daemon_source() -> str:
return """#!/usr/bin/env python3
from __future__ import annotations
import io
import json
import os
import socket
import traceback
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
import FreeCAD
ROOT = Path.cwd().resolve()
SOCKET_PATH = Path(os.getenv("FREECAD_POOL_SOCKET", ".cad-home/freecad_pool.sock"))
if not SOCKET_PATH.is_absolute():
SOCKET_PATH = (ROOT / SOCKET_PATH).resolve()
SOCKET_PATH.parent.mkdir(parents=True, exist_ok=True)
try:
SOCKET_PATH.unlink()
except FileNotFoundError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(SOCKET_PATH))
os.chmod(SOCKET_PATH, 0o600)
server.listen(16)
def _jsonl(conn: socket.socket, payload: dict):
conn.sendall((json.dumps(payload, ensure_ascii=False) + "\\n").encode("utf-8"))
def _safe_script_path(rel: str) -> Path | None:
try:
candidate = (ROOT / rel).resolve()
candidate.relative_to(ROOT)
return candidate
except Exception:
return None
def _runtime_info() -> dict:
try:
version = ".".join(FreeCAD.Version()[:3])
except Exception:
version = ""
return {"ok": True, "version": version}
def _run_script(rel: str) -> dict:
script_path = _safe_script_path(rel)
if script_path is None or not script_path.exists():
return {"ok": False, "error": f"script not found: {rel}"}
code = script_path.read_text(encoding="utf-8")
buf_out = io.StringIO()
buf_err = io.StringIO()
scope = {"__name__": "__main__"}
try:
with redirect_stdout(buf_out), redirect_stderr(buf_err):
exec(compile(code, str(script_path), "exec"), scope, scope)
return {"ok": True, "stdout": buf_out.getvalue(), "stderr": buf_err.getvalue()}
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"stdout": buf_out.getvalue(),
"stderr": buf_err.getvalue(),
"traceback": traceback.format_exc(limit=8),
}
while True:
conn, _ = server.accept()
try:
data = b""
while b"\\n" not in data and len(data) < 4_000_000:
chunk = conn.recv(65536)
if not chunk:
break
data += chunk
if not data:
continue
line = data.split(b"\\n", 1)[0]
req = json.loads(line.decode("utf-8"))
action = str(req.get("action") or "")
if action == "ping":
_jsonl(conn, {"ok": True, "status": "ready"})
continue
if action == "runtime_info":
_jsonl(conn, _runtime_info())
continue
if action == "run_script":
_jsonl(conn, _run_script(str(req.get("script_rel") or "")))
continue
_jsonl(conn, {"ok": False, "error": f"unknown action: {action}"})
except Exception as exc:
_jsonl(conn, {"ok": False, "error": str(exc)})
finally:
try:
conn.close()
except Exception:
pass
"""
def _freecad_pool_request(payload: dict[str, Any], *, timeout: float) -> dict[str, Any]:
if not FREECAD_POOL_SOCKET.exists():
raise CadRuntimeError("freecad pool socket not available")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout)
client.connect(str(FREECAD_POOL_SOCKET))
client.sendall((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
chunks: list[bytes] = []
started = time.monotonic()
while True:
if time.monotonic() - started > timeout:
raise CadRuntimeError("freecad pool request timed out")
chunk = client.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
raw = b"".join(chunks).split(b"\n", 1)[0].decode("utf-8", errors="replace").strip()
if not raw:
raise CadRuntimeError("freecad pool returned empty payload")
try:
payload_out = json.loads(raw)
except json.JSONDecodeError as exc:
raise CadRuntimeError(f"freecad pool invalid JSON: {raw}") from exc
if not isinstance(payload_out, dict):
raise CadRuntimeError("freecad pool response is not an object")
return payload_out
def _freecad_pool_ping(*, timeout: float = 1.0) -> bool:
try:
response = _freecad_pool_request({"action": "ping"}, timeout=timeout)
return bool(response.get("ok"))
except Exception:
return False
def _ensure_freecad_pool() -> bool:
if not _freecad_pool_enabled():
return False
ensure_cad_home()
if _freecad_pool_ping(timeout=0.5):
return True
daemon_source = _freecad_pool_daemon_source()
FREECAD_POOL_DAEMON.write_text(daemon_source, encoding="utf-8")
os.chmod(FREECAD_POOL_DAEMON, 0o700)
if FREECAD_POOL_SOCKET.exists():
cleanup_runtime_path(FREECAD_POOL_SOCKET)
proc = run_cad_stack_background(
"freecad-cmd",
str(FREECAD_POOL_DAEMON.relative_to(ROOT)),
env={"FREECAD_POOL_SOCKET": FREECAD_POOL_SOCKET_REL},
log_path=FREECAD_POOL_LOG,
)
try:
FREECAD_POOL_PID.write_text(str(proc.pid), encoding="utf-8")
except Exception:
pass
deadline = time.monotonic() + _freecad_pool_start_timeout()
while time.monotonic() < deadline:
if proc.poll() is not None:
break
if _freecad_pool_ping(timeout=0.5):
return True
time.sleep(0.1)
try:
if FREECAD_POOL_LOG.exists():
excerpt = FREECAD_POOL_LOG.read_text(encoding="utf-8", errors="replace")[-4000:]
if excerpt.strip():
FREECAD_POOL_LOG.write_text(excerpt, encoding="utf-8")
except Exception:
pass
return False
def run_freecad_script(
script_rel_path: str,
*,
timeout: float = 120.0,
prefer_pool: bool = True,
clean_env: bool = False,
) -> subprocess.CompletedProcess[str]:
if prefer_pool and _ensure_freecad_pool():
try:
response = _freecad_pool_request(
{"action": "run_script", "script_rel": script_rel_path},
timeout=max(5.0, timeout + 5.0),
)
ok = bool(response.get("ok"))
return subprocess.CompletedProcess(
args=["freecad-pool", script_rel_path],
returncode=0 if ok else 1,
stdout=str(response.get("stdout") or ""),
stderr=str(response.get("stderr") or response.get("error") or ""),
)
except Exception:
pass
return run_cad_stack("freecad-cmd", script_rel_path, timeout=timeout, clean_env=clean_env)
def require_process_success(
proc: subprocess.CompletedProcess[str],
*,
context: str,
) -> subprocess.CompletedProcess[str]:
if proc.returncode == 0:
return proc
error = proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}"
raise CadRuntimeError(f"{context} failed: {error}")