programgarden 1.24.2__tar.gz → 1.26.0__tar.gz

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.
Files changed (37) hide show
  1. {programgarden-1.24.2 → programgarden-1.26.0}/PKG-INFO +4 -4
  2. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/client.py +2 -2
  3. programgarden-1.26.0/programgarden/code_worker.py +486 -0
  4. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/deep_fixtures.py +57 -0
  5. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/executor.py +733 -393
  6. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/node_runner.py +5 -1
  7. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resolver.py +117 -87
  8. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/registry_tools.py +7 -51
  9. {programgarden-1.24.2 → programgarden-1.26.0}/pyproject.toml +4 -4
  10. {programgarden-1.24.2 → programgarden-1.26.0}/README.md +0 -0
  11. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/__init__.py +0 -0
  12. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/binding_validator.py +0 -0
  13. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/context.py +0 -0
  14. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/database/__init__.py +0 -0
  15. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/database/checkpoint_manager.py +0 -0
  16. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/database/query_builder.py +0 -0
  17. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/database/workflow_position_tracker.py +0 -0
  18. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  19. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/plugin/__init__.py +0 -0
  20. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/plugin/sandbox.py +0 -0
  21. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/providers/__init__.py +0 -0
  22. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/providers/llm_errors.py +0 -0
  23. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/providers/llm_provider.py +0 -0
  24. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/reconnect_handler.py +0 -0
  25. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resource/__init__.py +0 -0
  26. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resource/context.py +0 -0
  27. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resource/limiter.py +0 -0
  28. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resource/monitor.py +0 -0
  29. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/resource/throttle.py +0 -0
  30. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/semantic_rules.py +0 -0
  31. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/__init__.py +0 -0
  32. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/credential_tools.py +0 -0
  33. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/definition_tools.py +0 -0
  34. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/event_tools.py +0 -0
  35. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/job_tools.py +0 -0
  36. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.24.2 → programgarden-1.26.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.24.2
3
+ Version: 1.26.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
15
15
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
- Requires-Dist: programgarden-community (>=1.13.9,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.15.1,<2.0.0)
20
- Requires-Dist: programgarden-finance (>=1.6.11,<2.0.0)
18
+ Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.18.0,<2.0.0)
20
+ Requires-Dist: programgarden-finance (>=1.6.14,<2.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
23
23
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -269,10 +269,10 @@ class ProgramGarden:
269
269
  return [job.get_state() for job in self.executor.list_jobs()]
270
270
 
271
271
  @staticmethod
272
- def list_node_types(category: Optional[str] = None, include_dynamic: bool = True) -> List[Dict[str, Any]]:
272
+ def list_node_types(category: Optional[str] = None) -> List[Dict[str, Any]]:
273
273
  """List available node types"""
274
274
  from programgarden.tools import list_node_types
275
- return list_node_types(category, include_dynamic=include_dynamic)
275
+ return list_node_types(category)
276
276
 
277
277
  @staticmethod
278
278
  def list_plugins(
@@ -0,0 +1,486 @@
1
+ """CodeNode subprocess isolation — fixed worker pool (Layer 4).
2
+
3
+ CodeNode runs untrusted Python. This module always executes that code in a
4
+ **credential-free child process** so it can never touch the parent's in-memory
5
+ app keys, broker sessions, or token cache. Design constraints (plan §2 Layer 4):
6
+
7
+ * ALWAYS on — there is no public switch to run CodeNode in-process. The only
8
+ exception is a no-spawn environment (some serverless/embedded runtimes),
9
+ where a loudly-warned in-process fallback runs (Layers 1-3 still hold).
10
+ * Fixed, long-lived worker pool (spawn start method) — NOT spawn-per-exec, to
11
+ avoid K8s OOMKill / per-call memory churn.
12
+ * 🔴 Contract 1 (isolation integrity): the child receives only a scrubbed,
13
+ credential-free snapshot (data/params/ctx_snapshot). App keys, broker, and
14
+ session objects are never serialized into the task.
15
+ * 🔴 Contract 2 (safe deserialization): the child returns a JSON **string**
16
+ only. The parent json-loads it — it never unpickles an arbitrary object
17
+ graph from the untrusted child (which would be an RCE back into the parent).
18
+ * Fresh exec namespace every run (no cross-CodeNode state leak); the process
19
+ is reused, the namespace is not. Crash/timeout → kill + respawn.
20
+ * auto-iterate is batched: CodeNode receives the whole upstream array in
21
+ `data` and loops in-code, so N items = one IPC round-trip, not N.
22
+
23
+ ⚠️ Residual risk (be honest): the child is a plain spawn subprocess. It does not
24
+ inherit the parent's memory (app keys live only in the parent), and this module
25
+ scrubs secret-looking env vars from the child. But a Python-level sandbox escape
26
+ that reaches the OS could still read world-readable files (mount a per-user pod
27
+ and keep secrets like `sandbox/secret.env` out of it / restrict file perms) or
28
+ open the network (apply a no-egress NetworkPolicy). True containment of hostile
29
+ code is an OS/infra concern (namespaces, seccomp, egress policy), not something
30
+ this pool guarantees. The AST screen (core) is hardened defense-in-depth.
31
+
32
+ All user-facing error envelopes are English (chatbot consumer contract).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import logging
38
+ import multiprocessing
39
+ import os
40
+ import queue
41
+ import threading
42
+ import traceback
43
+ from typing import Any, Dict, List, Optional
44
+
45
+ logger = logging.getLogger("programgarden.code_worker")
46
+
47
+ # Per-call wall-clock limit (infinite-loop guard). Overridable via env.
48
+ _DEFAULT_TIMEOUT_SEC = float(os.environ.get("PG_CODE_NODE_TIMEOUT", "30"))
49
+ # Fixed pool size. Small by default (memory bound, not a parallelism win under
50
+ # a shared pod CPU quota). Overridable via env.
51
+ _DEFAULT_WORKERS = max(1, int(os.environ.get("PG_CODE_NODE_WORKERS", "2")))
52
+
53
+
54
+ # ─────────────────────────────────────────────────────────────────────────
55
+ # Child side
56
+ # ─────────────────────────────────────────────────────────────────────────
57
+
58
+ class _SandboxedContext:
59
+ """Read-only context handed to CodeNode `execute(data, params, context)`.
60
+
61
+ Exposes ONLY safe helper namespaces (mirroring expression bindings), a
62
+ risk-tracker read snapshot, and non-secret workflow meta. It deliberately
63
+ has NO get_credential / _secrets / broker / executor — the credential
64
+ access path simply does not exist here (structural capability removal), and
65
+ the process itself holds no app keys anyway.
66
+ """
67
+
68
+ __slots__ = ("date", "finance", "stats", "format", "lst", "_risk",
69
+ "job_id", "dry_run", "iteration_index", "iteration_total")
70
+
71
+ def __init__(self, snapshot: Dict[str, Any]):
72
+ from programgarden_core.expression.evaluator import (
73
+ DateNamespace, FinanceNamespace, StatsNamespace,
74
+ FormatNamespace, ListNamespace,
75
+ )
76
+ self.date = DateNamespace()
77
+ self.finance = FinanceNamespace()
78
+ self.stats = StatsNamespace()
79
+ self.format = FormatNamespace()
80
+ self.lst = ListNamespace()
81
+ self._risk: Dict[str, Any] = snapshot.get("risk", {}) or {}
82
+ self.job_id = snapshot.get("job_id")
83
+ self.dry_run = bool(snapshot.get("dry_run", False))
84
+ self.iteration_index = int(snapshot.get("iteration_index", 0) or 0)
85
+ self.iteration_total = int(snapshot.get("iteration_total", 0) or 0)
86
+
87
+ def get_hwm(self, symbol: str) -> Optional[float]:
88
+ """High-water-mark price for a symbol (read-only snapshot), or None."""
89
+ return (self._risk.get("hwm", {}) or {}).get(symbol, {}).get("hwm_price")
90
+
91
+ def get_drawdown(self, symbol: str) -> Optional[float]:
92
+ """Current drawdown % for a symbol (read-only snapshot), or None."""
93
+ return (self._risk.get("hwm", {}) or {}).get(symbol, {}).get("drawdown_pct")
94
+
95
+ def risk_snapshot(self) -> Dict[str, Any]:
96
+ """Full read-only risk snapshot (copy)."""
97
+ return dict(self._risk)
98
+
99
+
100
+ def _error_envelope(error_code: str, message: str, *, suggestion: Optional[str] = None,
101
+ line: Optional[int] = None, tb: Optional[str] = None) -> Dict[str, Any]:
102
+ return {
103
+ "ok": False,
104
+ "error_code": error_code,
105
+ "message": message,
106
+ "suggestion": suggestion,
107
+ "line": line,
108
+ "traceback": tb,
109
+ }
110
+
111
+
112
+ def _run_one(task: Dict[str, Any], compile_cache: Dict[int, Any]) -> str:
113
+ """Execute one CodeNode task inside the child; return a JSON envelope string.
114
+
115
+ NEVER returns a Python object across the pipe (Contract 2) — always a str.
116
+ """
117
+ import inspect
118
+
119
+ code = task["code"]
120
+ node_id = task["node_id"]
121
+ data = task.get("data")
122
+ params = task.get("params") or {}
123
+ ctx_snapshot = task.get("ctx_snapshot") or {}
124
+ allowed_imports = set(task.get("allowed_imports") or [])
125
+
126
+ from programgarden_core.code_node import compile_code_node, build_restricted_builtins
127
+
128
+ # Compile (cached per worker by the FULL source string — never hash(code),
129
+ # which can collide and return a wrong, never-re-screened code object).
130
+ # Screen again here — the worker is the last line before exec, and the
131
+ # share-gate reuses the same scanner, so double-screening is cheap.
132
+ if len(compile_cache) > 256:
133
+ compile_cache.clear() # bound memory across many distinct sources
134
+ key = code
135
+ code_object = compile_cache.get(key)
136
+ if code_object is None:
137
+ screen = compile_code_node(code, node_id, screen=True,
138
+ allowed_imports=allowed_imports or None)
139
+ if not screen.ok:
140
+ return json.dumps(_error_envelope(
141
+ screen.error_code or "CODE_NODE_SYNTAX_ERROR",
142
+ screen.message or "CodeNode failed to compile",
143
+ suggestion=screen.suggestion, line=screen.line,
144
+ ))
145
+ code_object = screen.code_object
146
+ compile_cache[key] = code_object
147
+
148
+ # Fresh namespace every run → no state leak between CodeNode executions.
149
+ restricted = build_restricted_builtins(allowed_imports or None)
150
+ g: Dict[str, Any] = {"__builtins__": restricted, "__name__": "__code_node__"}
151
+
152
+ try:
153
+ exec(code_object, g) # noqa: S102 — sandboxed: restricted builtins + AST-screened
154
+ except Exception:
155
+ return json.dumps(_error_envelope(
156
+ "CODE_NODE_EXEC_ERROR",
157
+ "CodeNode module-level code raised during import.",
158
+ suggestion="Move side-effecting logic inside execute(); keep module level to defs/imports.",
159
+ tb=_short_tb(),
160
+ ))
161
+
162
+ fn = g.get("execute")
163
+ if fn is None or not callable(fn):
164
+ return json.dumps(_error_envelope(
165
+ "CODE_NODE_NO_EXECUTE",
166
+ "CodeNode code must define a callable named 'execute'.",
167
+ suggestion="Define async def execute(data, params, context):",
168
+ ))
169
+
170
+ context = _SandboxedContext(ctx_snapshot)
171
+
172
+ try:
173
+ result = fn(data, params, context)
174
+ if inspect.iscoroutine(result):
175
+ import asyncio
176
+ result = asyncio.run(result)
177
+ elif inspect.isawaitable(result):
178
+ import asyncio
179
+
180
+ async def _await_any(x):
181
+ return await x
182
+
183
+ result = asyncio.run(_await_any(result))
184
+ except Exception:
185
+ return json.dumps(_error_envelope(
186
+ "CODE_NODE_EXEC_ERROR",
187
+ "CodeNode execute() raised an exception.",
188
+ suggestion="Fix the runtime error shown in the traceback.",
189
+ tb=_short_tb(),
190
+ ))
191
+
192
+ # Contract 2: serialize the raw return to JSON here. `allow_nan=False`
193
+ # rejects NaN/Infinity (which json.dumps would otherwise emit as invalid
194
+ # `NaN`/`Infinity` tokens that quietly round-trip) — surface a clear
195
+ # structured error rather than silently passing a bad value.
196
+ try:
197
+ return json.dumps({"ok": True, "value": result}, allow_nan=False)
198
+ except (TypeError, ValueError):
199
+ return json.dumps(_error_envelope(
200
+ "CODE_NODE_EXEC_ERROR",
201
+ "CodeNode return value is not JSON-serializable (custom object, set, datetime, or NaN/Infinity).",
202
+ suggestion="Return only finite JSON-safe data (dict/list/str/number/bool/None).",
203
+ ))
204
+
205
+
206
+ def _short_tb(limit: int = 6) -> str:
207
+ """Traceback tail with the worker frames trimmed for a readable summary."""
208
+ tb = traceback.format_exc()
209
+ lines = tb.strip().splitlines()
210
+ if len(lines) > limit * 2:
211
+ lines = lines[:2] + [" ..."] + lines[-(limit * 2):]
212
+ return "\n".join(lines)
213
+
214
+
215
+ # Env var name fragments that likely carry secrets. Scrubbed from the child's
216
+ # environment at startup so an in-child escape cannot read app keys / tokens
217
+ # from os.environ (blast-radius reduction — NOT a containment guarantee; file
218
+ # and network access still require OS-level isolation at the infra layer).
219
+ _SECRET_ENV_FRAGMENTS = (
220
+ "SECRET", "TOKEN", "PASSWORD", "PASSWD", "APPKEY", "APPSECRET",
221
+ "APP_KEY", "APP_SECRET", "PYPI", "CREDENTIAL", "PRIVATE", "API_KEY",
222
+ "APIKEY", "ACCESS_KEY", "AUTH", "SESSION", "COOKIE", "AWS_", "GCP_",
223
+ "AZURE_", "LS_",
224
+ )
225
+
226
+
227
+ def _scrub_child_env() -> None:
228
+ """Delete secret-looking env vars in this (child) process. Best-effort."""
229
+ for k in list(os.environ.keys()):
230
+ ku = k.upper()
231
+ if any(frag in ku for frag in _SECRET_ENV_FRAGMENTS):
232
+ try:
233
+ del os.environ[k]
234
+ except Exception:
235
+ pass
236
+
237
+
238
+ def _worker_main(conn, ready_evt=None) -> None:
239
+ """Child entry point: loop reading tasks, exec, send back a JSON string."""
240
+ _scrub_child_env()
241
+ compile_cache: Dict[str, Any] = {}
242
+ try:
243
+ while True:
244
+ try:
245
+ task = conn.recv()
246
+ except (EOFError, KeyboardInterrupt):
247
+ break
248
+ if task is None or (isinstance(task, dict) and task.get("cmd") == "shutdown"):
249
+ break
250
+ try:
251
+ payload = _run_one(task, compile_cache)
252
+ except Exception:
253
+ # Last-resort guard — the worker must always reply with a str.
254
+ payload = json.dumps(_error_envelope(
255
+ "CODE_NODE_EXEC_ERROR",
256
+ "CodeNode worker encountered an internal error.",
257
+ tb=_short_tb(),
258
+ ))
259
+ conn.send(payload) # always a str (Contract 2)
260
+ finally:
261
+ try:
262
+ conn.close()
263
+ except Exception:
264
+ pass
265
+
266
+
267
+ # ─────────────────────────────────────────────────────────────────────────
268
+ # Parent side
269
+ # ─────────────────────────────────────────────────────────────────────────
270
+
271
+ class _Worker:
272
+ __slots__ = ("proc", "conn")
273
+
274
+ def __init__(self, proc, conn):
275
+ self.proc = proc
276
+ self.conn = conn
277
+
278
+
279
+ class CodeWorkerPool:
280
+ """Fixed pool of long-lived spawn workers with a thread-safe free-list."""
281
+
282
+ def __init__(self, num_workers: int = _DEFAULT_WORKERS):
283
+ self._num = max(1, num_workers)
284
+ self._ctx = multiprocessing.get_context("spawn")
285
+ self._free: "queue.Queue[_Worker]" = queue.Queue()
286
+ self._all: List[_Worker] = []
287
+ self._lock = threading.Lock()
288
+ self._started = False
289
+ self._usable = True
290
+
291
+ def _spawn_worker(self) -> _Worker:
292
+ parent_conn, child_conn = self._ctx.Pipe(duplex=True)
293
+ proc = self._ctx.Process(
294
+ target=_worker_main, args=(child_conn,), daemon=True,
295
+ name="pg-code-worker",
296
+ )
297
+ proc.start()
298
+ # The parent keeps only its end of the pipe.
299
+ try:
300
+ child_conn.close()
301
+ except Exception:
302
+ pass
303
+ return _Worker(proc, parent_conn)
304
+
305
+ def start(self) -> bool:
306
+ """Start the pool. Returns False if spawn is unavailable (→ fallback)."""
307
+ with self._lock:
308
+ if self._started:
309
+ return self._usable
310
+ try:
311
+ for _ in range(self._num):
312
+ w = self._spawn_worker()
313
+ self._all.append(w)
314
+ self._free.put(w)
315
+ self._started = True
316
+ self._usable = True
317
+ logger.info("CodeNode worker pool started (%d spawn workers)", self._num)
318
+ except Exception as e:
319
+ self._usable = False
320
+ self._started = True
321
+ logger.error("CodeNode worker pool failed to start: %s", e)
322
+ return self._usable
323
+
324
+ @property
325
+ def usable(self) -> bool:
326
+ return self._usable
327
+
328
+ def _replace(self, worker: _Worker) -> None:
329
+ """Terminate a dead/timed-out worker and put a fresh one on the free-list."""
330
+ try:
331
+ worker.proc.terminate()
332
+ except Exception:
333
+ pass
334
+ try:
335
+ worker.conn.close()
336
+ except Exception:
337
+ pass
338
+ with self._lock:
339
+ if worker in self._all:
340
+ self._all.remove(worker)
341
+ try:
342
+ nw = self._spawn_worker()
343
+ self._all.append(nw)
344
+ self._free.put(nw)
345
+ except Exception as e:
346
+ logger.error("CodeNode worker respawn failed: %s", e)
347
+ self._usable = False
348
+
349
+ def run(self, task: Dict[str, Any], timeout: float = _DEFAULT_TIMEOUT_SEC) -> Dict[str, Any]:
350
+ """Run one task on an idle worker (blocking). Returns an envelope dict.
351
+
352
+ Enforces Contract 2 (payload must be a JSON string) and the per-call
353
+ timeout (kill + respawn on overrun).
354
+ """
355
+ if not self._usable:
356
+ return _error_envelope(
357
+ "CODE_NODE_EXEC_ERROR",
358
+ "CodeNode worker pool is not usable in this environment.",
359
+ suggestion="Run in an environment that supports process spawning.",
360
+ )
361
+ # Acquire an idle worker with a bounded wait. A plain blocking get()
362
+ # would deadlock a caller forever if a respawn failure has permanently
363
+ # drained the free-list; time out into a structured error instead.
364
+ acquire_timeout = timeout * 2 + 30
365
+ try:
366
+ worker = self._free.get(timeout=acquire_timeout)
367
+ except queue.Empty:
368
+ return _error_envelope(
369
+ "CODE_NODE_EXEC_ERROR",
370
+ "CodeNode worker pool is saturated or drained (no worker available).",
371
+ suggestion="Reduce concurrent CodeNode load; a worker may have failed to respawn.",
372
+ )
373
+ try:
374
+ worker.conn.send(task)
375
+ if not worker.conn.poll(timeout):
376
+ logger.warning("CodeNode '%s' timed out after %ss — restarting worker",
377
+ task.get("node_id"), timeout)
378
+ self._replace(worker)
379
+ return _error_envelope(
380
+ "CODE_NODE_EXEC_ERROR",
381
+ f"CodeNode timed out after {timeout:g}s.",
382
+ suggestion="Avoid unbounded loops / heavy work; the worker was restarted.",
383
+ )
384
+ payload = worker.conn.recv()
385
+ except (EOFError, BrokenPipeError, OSError, ConnectionError):
386
+ logger.warning("CodeNode '%s' worker crashed — restarting", task.get("node_id"))
387
+ self._replace(worker)
388
+ return _error_envelope(
389
+ "CODE_NODE_EXEC_ERROR",
390
+ "CodeNode worker process crashed.",
391
+ suggestion="Check for native crashes / memory exhaustion in the code; the worker was restarted.",
392
+ )
393
+
394
+ # Contract 2 guard: we must only ever json.loads a string.
395
+ if not isinstance(payload, str):
396
+ self._replace(worker)
397
+ return _error_envelope(
398
+ "CODE_NODE_EXEC_ERROR",
399
+ "CodeNode worker returned a non-JSON payload.",
400
+ suggestion="Internal isolation error; the worker was restarted.",
401
+ )
402
+ self._free.put(worker) # healthy → back to the pool
403
+ try:
404
+ return json.loads(payload)
405
+ except (TypeError, ValueError):
406
+ return _error_envelope(
407
+ "CODE_NODE_EXEC_ERROR",
408
+ "CodeNode worker returned malformed JSON.",
409
+ )
410
+
411
+ def shutdown(self) -> None:
412
+ with self._lock:
413
+ for w in self._all:
414
+ try:
415
+ w.conn.send({"cmd": "shutdown"})
416
+ except Exception:
417
+ pass
418
+ try:
419
+ w.proc.terminate()
420
+ except Exception:
421
+ pass
422
+ self._all.clear()
423
+ self._started = False
424
+
425
+
426
+ # ── Module singleton + high-level entry ────────────────────────────────────
427
+
428
+ _POOL: Optional[CodeWorkerPool] = None
429
+ _POOL_LOCK = threading.Lock()
430
+
431
+
432
+ def get_code_worker_pool() -> CodeWorkerPool:
433
+ global _POOL
434
+ if _POOL is None:
435
+ with _POOL_LOCK:
436
+ if _POOL is None:
437
+ _POOL = CodeWorkerPool()
438
+ _POOL.start()
439
+ return _POOL
440
+
441
+
442
+ def _run_in_process_fallback(task: Dict[str, Any]) -> Dict[str, Any]:
443
+ """Layers 1-3 without the process boundary. Used ONLY when spawn is
444
+ unavailable. Loudly warned — this is not a normal-path toggle."""
445
+ logger.warning(
446
+ "CodeNode '%s' running IN-PROCESS (spawn unavailable). Layers 1-3 "
447
+ "(scrubbed context + restricted builtins + AST screen) still apply, but "
448
+ "the subprocess memory boundary (Layer 4) is NOT active. Only expected "
449
+ "on no-spawn runtimes.", task.get("node_id"),
450
+ )
451
+ cache: Dict[int, Any] = {}
452
+ payload = _run_one(task, cache)
453
+ try:
454
+ return json.loads(payload)
455
+ except (TypeError, ValueError):
456
+ return _error_envelope("CODE_NODE_EXEC_ERROR", "CodeNode returned malformed JSON.")
457
+
458
+
459
+ def run_code_node_sandboxed(
460
+ *,
461
+ code: str,
462
+ node_id: str,
463
+ data: Any,
464
+ params: Dict[str, Any],
465
+ ctx_snapshot: Dict[str, Any],
466
+ allowed_imports: Optional[List[str]] = None,
467
+ timeout: float = _DEFAULT_TIMEOUT_SEC,
468
+ ) -> Dict[str, Any]:
469
+ """Run CodeNode code in the isolated worker pool (or in-process fallback).
470
+
471
+ Returns an envelope dict:
472
+ {"ok": True, "value": <raw return>}
473
+ {"ok": False, "error_code": ..., "message": ..., "suggestion": ..., "line": ..., "traceback": ...}
474
+ """
475
+ task: Dict[str, Any] = {
476
+ "code": code,
477
+ "node_id": node_id,
478
+ "data": data,
479
+ "params": params or {},
480
+ "ctx_snapshot": ctx_snapshot or {},
481
+ "allowed_imports": list(allowed_imports) if allowed_imports else None,
482
+ }
483
+ pool = get_code_worker_pool()
484
+ if pool.usable:
485
+ return pool.run(task, timeout=timeout)
486
+ return _run_in_process_fallback(task)
@@ -318,6 +318,63 @@ def real_order_event_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
318
318
  }
319
319
 
320
320
 
321
+ def futures_contract_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
322
+ """FuturesContractNode deep fixture (no o3101 master query).
323
+
324
+ Real shape: ``{"symbols": [{exchange, symbol}], "contracts": [...], "count": int}``.
325
+
326
+ The month code is derived from the fixture anchor, not the wall clock, so a deep
327
+ run is reproducible. The *symbol string* is therefore not a real listed contract —
328
+ that is fine and deliberate: deep_validate checks field/type/flow integrity, and
329
+ downstream fixtures key off the symbol string only as an opaque identifier.
330
+ """
331
+ raw = config.get("base_products") or []
332
+ if isinstance(raw, str):
333
+ raw = [p.strip() for p in raw.split(",") if p.strip()]
334
+ products = [str(p).strip().upper() for p in raw if str(p).strip()] or ["HMH"]
335
+
336
+ # 거래소는 ExchCd 로 정규화한다. config 값을 그대로 되뱉으면, 형제 노드의 enum('6')을
337
+ # 넣은 워크플로우가 게이트를 통과한 뒤 첫 라이브 실행에서만 죽는다 — 게이트의 존재 이유가 없어진다.
338
+ _ENUM_TO_EXCHCD = {"1": "", "2": "CME", "3": "SGX", "4": "EUREX", "5": "ICE", "6": "HKEX", "7": "OSE"}
339
+ exchange = str(config.get("futures_exchange") or "").strip().upper()
340
+ exchange = _ENUM_TO_EXCHCD.get(exchange, exchange) or "HKEX"
341
+
342
+ # 월물 문자 코드 (F=1월 … Z=12월) — 실제 노드와 같은 표기를 쓴다.
343
+ # contract_selection 을 실제 노드와 같은 규칙으로 반영한다(front=당월, next=익월,
344
+ # quarterly=3·6·9·12월 중 최근접). fixture 가 이걸 무시하면 세 설정이 같은 심볼을 내
345
+ # 배선 검증이 selection 오류를 못 잡는다.
346
+ month_letters = "FGHJKMNQUVXZ"
347
+ selection = str(config.get("contract_selection") or "front").strip().lower()
348
+ month = _FIXTURE_ANCHOR.month
349
+ if selection == "next":
350
+ month += 1
351
+ elif selection == "quarterly":
352
+ while month % 3:
353
+ month += 1
354
+ year = _FIXTURE_ANCHOR.year + (month - 1) // 12
355
+ month = (month - 1) % 12 + 1
356
+ letter = month_letters[month - 1]
357
+ yy = f"{year % 100:02d}"
358
+
359
+ contracts: List[Dict[str, Any]] = []
360
+ for product in products:
361
+ contracts.append(
362
+ {
363
+ "symbol": f"{product}{letter}{yy}",
364
+ "exchange": exchange,
365
+ "base_product": product,
366
+ "base_product_name": product,
367
+ "name": f"{product}({year}.{month:02d})",
368
+ "contract_month": f"{year:04d}-{month:02d}",
369
+ }
370
+ )
371
+ return {
372
+ "symbols": [{"exchange": c["exchange"], "symbol": c["symbol"]} for c in contracts],
373
+ "contracts": contracts,
374
+ "count": len(contracts),
375
+ }
376
+
377
+
321
378
  def market_status_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
322
379
  """MarketStatusNode deep fixture (markets open).
323
380