@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 +1 -1
- package/python/rebuild_daemon.py +167 -4
package/package.json
CHANGED
package/python/rebuild_daemon.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|