@forgent3d/cad-runtime 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgent3d/cad-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Shared CAD runtime protocol, types, and pure utilities for Forgent3D cloud services.",
5
5
  "license": "MIT",
6
6
  "author": "Forgent3D",
@@ -20,10 +20,12 @@ Protocol (newline JSON bodies):
20
20
  metadataKeys, metadataAnchors, error?}, GLB written to `output`.
21
21
  """
22
22
  import argparse
23
+ import importlib.util
23
24
  import json
24
25
  import os
25
26
  import sys
26
27
  import traceback
28
+ from collections import OrderedDict
27
29
  from http.server import BaseHTTPRequestHandler, HTTPServer
28
30
 
29
31
  HERE = os.path.dirname(os.path.abspath(__file__))
@@ -32,12 +34,31 @@ if HERE not in sys.path:
32
34
 
33
35
  import export_runner as er # reuse the exact build/export/metadata logic
34
36
 
37
+
38
+ def _load_module(filename: str, modname: str):
39
+ spec = importlib.util.spec_from_file_location(modname, os.path.join(HERE, filename))
40
+ mod = importlib.util.module_from_spec(spec)
41
+ spec.loader.exec_module(mod)
42
+ return mod
43
+
44
+
45
+ # aicad-script.py has a hyphen -> load it by path under an importable name so we can
46
+ # reuse its probe helpers (split_target, load_namespace, build_probe_namespace, ...).
47
+ aicad_script = _load_module("aicad-script.py", "aicad_script")
48
+
35
49
  # Modules + sys.path present right after warmup. Anything a request adds (the user's
36
50
  # part.py and its local imports) is rolled back afterwards so edits rebuild fresh and
37
51
  # sys.path/sys.modules don't grow unbounded across requests.
38
52
  _BASE_MODULES: set = set()
39
53
  _BASE_SYS_PATH: list = []
40
54
 
55
+ # Built namespaces cached for probe, keyed by (project, model, part). Probes are bursty
56
+ # on the same unchanged model, so this lets the 2nd..Nth probe skip the rebuild entirely
57
+ # (not just the import). Invalidated when part.py or any project-local import it pulled in
58
+ # changes (mtime+size), so editing code still probes fresh.
59
+ _PROBE_CACHE: "OrderedDict[tuple, dict]" = OrderedDict()
60
+ _PROBE_CACHE_MAX = 8
61
+
41
62
 
42
63
  def _warmup() -> None:
43
64
  global _BASE_MODULES, _BASE_SYS_PATH
@@ -126,6 +147,107 @@ def _build_export(req: dict) -> dict:
126
147
  return payload
127
148
 
128
149
 
150
+ def _file_sig(path):
151
+ try:
152
+ st = os.stat(path)
153
+ return (st.st_mtime_ns, st.st_size)
154
+ except OSError:
155
+ return None
156
+
157
+
158
+ def _deps_fresh(deps: dict) -> bool:
159
+ return all(_file_sig(f) == sig for f, sig in deps.items())
160
+
161
+
162
+ def _load_ns_with_deps(model: str, part: str):
163
+ """exec the model source and record the project-local files it pulled in, so the
164
+ cached namespace can be invalidated when part.py or any local helper changes."""
165
+ before = set(sys.modules)
166
+ ns, source = aicad_script.load_namespace(model, part)
167
+ deps = {source: _file_sig(source)}
168
+ # params.json is read at build time (dims/materials) but isn't a Python import, so
169
+ # track it explicitly; otherwise a params edit would serve a stale cached namespace.
170
+ params = os.path.join(aicad_script.MODELS_DIR, model, "params.json")
171
+ if os.path.isfile(params):
172
+ deps[params] = _file_sig(params)
173
+ root = os.path.abspath(aicad_script.PROJECT_ROOT) + os.sep
174
+ for name in set(sys.modules) - before:
175
+ mod = sys.modules.get(name)
176
+ f = getattr(mod, "__file__", None)
177
+ if f and os.path.abspath(f).startswith(root):
178
+ deps[f] = _file_sig(f)
179
+ return ns, deps
180
+
181
+
182
+ def _cached_ns(model: str, part: str):
183
+ key = (aicad_script.PROJECT_ROOT, model, part)
184
+ entry = _PROBE_CACHE.get(key)
185
+ if entry and _deps_fresh(entry["deps"]):
186
+ _PROBE_CACHE.move_to_end(key)
187
+ return entry["ns"], True
188
+ ns, deps = _load_ns_with_deps(model, part)
189
+ _PROBE_CACHE[key] = {"ns": ns, "deps": deps}
190
+ _PROBE_CACHE.move_to_end(key)
191
+ while len(_PROBE_CACHE) > _PROBE_CACHE_MAX:
192
+ _PROBE_CACHE.popitem(last=False)
193
+ return ns, False
194
+
195
+
196
+ def _probe(req: dict) -> dict:
197
+ """Mirror aicad-script.py command_probe, but reuse the warm interpreter and the
198
+ cached built namespace. Returns the same payload shape command_probe does."""
199
+ project = os.path.abspath(str(req.get("project") or os.getcwd()))
200
+ aicad_script.PROJECT_ROOT = project
201
+ aicad_script.MODELS_DIR = os.path.join(project, "models")
202
+
203
+ argv = req.get("argv") or []
204
+ try:
205
+ flags, positionals = aicad_script.parse_flags(argv)
206
+ if positionals:
207
+ raise ValueError('probe expects flags, e.g. -component model/part -expr "top_edges(part)"')
208
+ component = aicad_script.flag_value(flags, "component", "target", "part")
209
+ component_b = aicad_script.flag_value(flags, "component-b", "componentB", "other-component", "other", "b")
210
+ expression = str(aicad_script.flag_value(flags, "expr", "expression") or "").strip()
211
+ if not expression:
212
+ expression = "bbox_relation(part_a, part_b)" if component_b else "part"
213
+
214
+ model, part = aicad_script.split_target(component or "")
215
+ ns, hit_a = _cached_ns(model, part)
216
+ if ns.get("result", None) is None:
217
+ raise ValueError("part.py must define a global result before probing")
218
+
219
+ ns_b = None
220
+ model_b = part_b = None
221
+ cache_hits = [hit_a]
222
+ if component_b:
223
+ model_b, part_b = aicad_script.split_target(component_b)
224
+ ns_b, hit_b = _cached_ns(model_b, part_b)
225
+ cache_hits.append(hit_b)
226
+ if ns_b.get("result", None) is None:
227
+ raise ValueError("componentB part.py must define a global result before probing")
228
+
229
+ probe_ns = aicad_script.build_probe_namespace(ns, ns_b)
230
+ value = eval(expression, probe_ns, probe_ns)
231
+ payload = {
232
+ "ok": True,
233
+ "script": "probe",
234
+ "model": model,
235
+ "part": part,
236
+ "expression": expression,
237
+ "value": aicad_script.summarize_value(value),
238
+ "cached": all(cache_hits),
239
+ }
240
+ if model_b and part_b:
241
+ payload["componentB"] = {"model": model_b, "part": part_b}
242
+ return payload
243
+ except Exception as exc:
244
+ # Deterministic error (bad expression / missing result / model build error): return
245
+ # it with the traceback so the agent gets the same info the cold path would give —
246
+ # re-running cold would only repeat it after paying the import.
247
+ return {"ok": False, "script": "probe", "error": f"{type(exc).__name__}: {exc}",
248
+ "traceback": traceback.format_exc(limit=8)}
249
+
250
+
129
251
  class Handler(BaseHTTPRequestHandler):
130
252
  protocol_version = "HTTP/1.1"
131
253
 
@@ -147,7 +269,10 @@ class Handler(BaseHTTPRequestHandler):
147
269
  self._send(404, {"ok": False, "error": "not found"})
148
270
 
149
271
  def do_POST(self):
150
- if self.path.rstrip("/") != "/build_export":
272
+ route = self.path.rstrip("/")
273
+ handlers = {"/build_export": _build_export, "/probe": _probe}
274
+ handler = handlers.get(route)
275
+ if handler is None:
151
276
  self._send(404, {"ok": False, "error": "not found"})
152
277
  return
153
278
  try:
@@ -157,8 +282,7 @@ class Handler(BaseHTTPRequestHandler):
157
282
  self._send(400, {"ok": False, "error": f"bad request: {exc}"})
158
283
  return
159
284
  try:
160
- result = _build_export(req)
161
- self._send(200, result)
285
+ self._send(200, handler(req))
162
286
  except Exception as exc:
163
287
  print(f"[rebuild_daemon] handler error: {exc}", file=sys.stderr)
164
288
  traceback.print_exc()
@@ -194,6 +318,18 @@ def client_main(argv) -> int:
194
318
  return 3
195
319
 
196
320
 
321
+ # Written after warmup so a cheap files.exists() check (no python spawn) tells the
322
+ # cf-sandbox Worker the daemon is up. Keep in sync with SANDBOX_DAEMON_READY_MARKER.
323
+ READY_MARKER = "/tmp/.aicad-rebuild-daemon.ready"
324
+
325
+
326
+ def _remove_ready_marker() -> None:
327
+ try:
328
+ os.unlink(READY_MARKER)
329
+ except OSError:
330
+ pass
331
+
332
+
197
333
  def main() -> int:
198
334
  if len(sys.argv) > 1 and sys.argv[1] == "client":
199
335
  return client_main(sys.argv[2:])
@@ -203,13 +339,40 @@ def main() -> int:
203
339
  parser.add_argument("--port", type=int, default=8765)
204
340
  args = parser.parse_args()
205
341
 
342
+ # Bind FIRST so a duplicate launch (two prewarm/resolve calls racing) fails fast on
343
+ # the busy port instead of running a second ~2.2s warmup — the existing daemon keeps
344
+ # serving. The socket is listening immediately, so requests that arrive during warmup
345
+ # queue in the backlog and block until serve_forever() handles them — callers wait
346
+ # through warmup rather than being refused (and racing their own cold import).
347
+ try:
348
+ server = HTTPServer((args.host, args.port), Handler)
349
+ except OSError as exc:
350
+ print(f"[rebuild_daemon] port {args.port} busy ({exc}); another daemon owns it", file=sys.stderr)
351
+ return 0
352
+
206
353
  _warmup()
207
- server = HTTPServer((args.host, args.port), Handler)
354
+
355
+ import atexit
356
+ import signal
357
+ atexit.register(_remove_ready_marker)
358
+ for _sig in (signal.SIGTERM, signal.SIGINT):
359
+ try:
360
+ signal.signal(_sig, lambda *_: sys.exit(0))
361
+ except Exception:
362
+ pass
363
+ try:
364
+ with open(READY_MARKER, "w") as f:
365
+ f.write(str(os.getpid()))
366
+ except OSError:
367
+ pass
368
+
208
369
  print(f"[rebuild_daemon] ready on {args.host}:{args.port} pid={os.getpid()}", flush=True)
209
370
  try:
210
371
  server.serve_forever()
211
372
  except KeyboardInterrupt:
212
373
  pass
374
+ finally:
375
+ _remove_ready_marker()
213
376
  return 0
214
377
 
215
378