@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548

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 (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,308 @@
1
+ """_box — the on-box consumer seams shared by ``close`` and ``status`` (slice B1).
2
+
3
+ Both verbs reach OFF the composer's own artifacts into things the frozen runtime owns:
4
+ the ``openclaw senpi runtime …`` CLI and the Senpi MCP server. Every one of those touch
5
+ points is an INJECTABLE SEAM (mirroring ``install.py``'s ``create_fn``) so the whole slice
6
+ is tested without a box or network — tests fake the seam, production wires the default.
7
+
8
+ Three seams live here:
9
+
10
+ ``ListFn`` — ``() -> list[Runtime]``. Default: parse ``openclaw senpi runtime list``.
11
+ ``DeleteFn`` — ``(runtime_id, wallet) -> CliResult``. Default: ``openclaw senpi runtime delete``.
12
+ ``McpFn`` — ``(tool, **args) -> dict``. Default: a tiny stdlib streamable-HTTP client. The
13
+ tools asked of it: ``strategy_list`` + ``strategy_close`` (close, B1), plus
14
+ ``account_get_portfolio`` (balance preflight) + ``strategy_create_custom_strategy``
15
+ (create/fund the wallet) for ``fund`` (B3). Creds from
16
+ ``SENPI_AUTH_TOKEN`` / ``SENPI_API_KEY`` / ``SENPI_MCP_URL``; LOUD when absent.
17
+
18
+ The default impls run ONLY in production (the seam-faking tests never reach them), exactly
19
+ like ``install._default_create``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import subprocess
27
+ from dataclasses import dataclass
28
+ from typing import Any, Callable, Optional
29
+
30
+ # Non-terminal strategy statuses — filtered server-side so a strategy_list stays small
31
+ # (never pull a long CLOSED/FAILED history). Mirrors senpi-strategy-ops _cli/close.py.
32
+ LIVE_STATUSES = [
33
+ "ACTIVE", "PAUSED", "CREATE_WALLET", "FUND_WALLET", "INITIALIZE_POSITIONS",
34
+ "SUBSCRIBE_TRADER", "CLOSING_POSITIONS",
35
+ ]
36
+ # Terminal statuses — a strategy here is already done; never re-submit strategy_close.
37
+ CLOSED_STATUSES = ("CLOSED", "INACTIVE", "CLOSING_DONE", "TERMINATED", "FAILED")
38
+
39
+
40
+ # --------------------------------------------------------------------------- CLI seam result
41
+
42
+ @dataclass
43
+ class CliResult:
44
+ """What an ``openclaw`` shell-out returned; the seam yields this so callers read exit
45
+ code + text without knowing HOW it was produced (mirrors install.CreateResult)."""
46
+
47
+ returncode: int
48
+ stdout: str = ""
49
+ stderr: str = ""
50
+
51
+ @property
52
+ def ok(self) -> bool:
53
+ return self.returncode == 0
54
+
55
+ @property
56
+ def text(self) -> str:
57
+ return f"{self.stdout}\n{self.stderr}"
58
+
59
+
60
+ # A runtime row as the seam yields it: {"name", "wallet", "status", "source"} (tolerant).
61
+ Runtime = dict
62
+
63
+ ListFn = Callable[[], list[Runtime]]
64
+ DeleteFn = Callable[[str, str], CliResult]
65
+ McpFn = Callable[..., Any]
66
+
67
+
68
+ # --------------------------------------------------------------------------- MCP errors
69
+
70
+ class McpError(Exception):
71
+ """A JSON-RPC / tool-level MCP failure — never a silent None (a renamed or failed tool
72
+ must surface, not masquerade as 'no data'). Mirrors senpi-strategy-ops mcp_client.MCPError."""
73
+
74
+
75
+ class McpCredsMissing(McpError):
76
+ """SENPI_API_KEY / SENPI_MCP_URL not set — raised BEFORE any network so the close verb
77
+ can map it to a loud, teaching-shaped CMP223 rather than a bare transport error."""
78
+
79
+
80
+ # --------------------------------------------------------------------------- wallet matching
81
+
82
+ def wallet_match(a: Optional[str], b: Optional[str]) -> bool:
83
+ """Compare two wallet strings, tolerating the truncated ``0xabc…wxyz`` / ``0xabc...wxyz``
84
+ form ``runtime list`` prints in a TTY (full addresses when piped). Case-insensitive.
85
+ Ported from senpi-strategy-ops _cli.wallet_match."""
86
+ if not a or not b:
87
+ return False
88
+ a, b = str(a).strip().lower(), str(b).strip().lower()
89
+ if a == b:
90
+ return True
91
+ for x, y in ((a, b), (b, a)):
92
+ for sep in ("...", "…"):
93
+ if sep in x:
94
+ pre, _, suf = x.partition(sep)
95
+ if pre and suf and y.startswith(pre) and y.endswith(suf):
96
+ return True
97
+ return False
98
+
99
+
100
+ # --------------------------------------------------------------------------- runtime-list default
101
+
102
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
103
+
104
+
105
+ def parse_runtime_list(text: str) -> list[Runtime]:
106
+ """Parse the ``openclaw senpi runtime list`` text table (id / wallet / source / status).
107
+
108
+ ``runtime list`` has no ``--json`` passthrough (senpi-commands.ts prints a tab table), so
109
+ the text is the interface. Columns are tab- or 2+-space-separated; the header row starts
110
+ with ``id`` and carries ``wallet`` + ``status``. Tolerant of ANSI + a 'No runtimes' line."""
111
+ rows: list[Runtime] = []
112
+ seen_header = False
113
+ for raw in text.splitlines():
114
+ line = _ANSI.sub("", raw).strip()
115
+ if not line:
116
+ continue
117
+ low = line.lower()
118
+ if not seen_header:
119
+ if low.startswith("id") and "status" in low and "wallet" in low:
120
+ seen_header = True
121
+ continue
122
+ if "no runtimes" in low:
123
+ break
124
+ parts = [p for p in re.split(r"\s{2,}|\t+", line) if p]
125
+ if len(parts) >= 2:
126
+ rows.append({
127
+ "name": parts[0],
128
+ "wallet": parts[1],
129
+ "source": parts[2] if len(parts) > 2 else None,
130
+ "status": parts[-1],
131
+ })
132
+ return rows
133
+
134
+
135
+ def runtime_running(rt: Runtime) -> bool:
136
+ """True when a runtime row reports a live/running process status."""
137
+ s = str(rt.get("status") or "").strip().lower()
138
+ return s in ("running", "active", "live", "ok", "true", "healthy", "degraded")
139
+
140
+
141
+ def _run_cli(args: list[str], timeout: int = 60) -> CliResult:
142
+ """Shell out; SENPI_LOG_LEVEL=error keeps the plugin's stdout info logs from corrupting
143
+ parseable output (mirrors senpi-strategy-ops _cli.run_cli)."""
144
+ env = dict(os.environ, SENPI_LOG_LEVEL="error")
145
+ openclaw = os.environ.get("OPENCLAW_BIN", "openclaw")
146
+ argv = [openclaw if a == "openclaw" else a for a in args]
147
+ try:
148
+ p = subprocess.run( # noqa: S603 — fixed argv, no shell
149
+ argv, capture_output=True, text=True, timeout=timeout, env=env,
150
+ )
151
+ return CliResult(p.returncode, p.stdout or "", p.stderr or "")
152
+ except FileNotFoundError:
153
+ return CliResult(-1, "", f"command not found: {argv[0]}")
154
+ except subprocess.TimeoutExpired:
155
+ return CliResult(-1, "", f"timed out after {timeout}s: {' '.join(argv)}")
156
+
157
+
158
+ def default_list_runtimes() -> list[Runtime]:
159
+ """Production ListFn seam: ``openclaw senpi runtime list`` → parsed rows (empty on error —
160
+ the runtime registry simply isn't visible from a box without openclaw)."""
161
+ res = _run_cli(["openclaw", "senpi", "runtime", "list"])
162
+ return parse_runtime_list(res.stdout) if res.ok else []
163
+
164
+
165
+ def default_delete_runtime(runtime_id: str, wallet: str) -> CliResult:
166
+ """Production DeleteFn seam: shell out to the FROZEN ``openclaw senpi runtime delete``
167
+ consumer (senpi-commands.ts:110). ``--address`` scopes the delete to the bound wallet."""
168
+ args = ["openclaw", "senpi", "runtime", "delete"]
169
+ if runtime_id:
170
+ args += ["--id", runtime_id]
171
+ if wallet:
172
+ args += ["--address", wallet]
173
+ return _run_cli(args, timeout=60)
174
+
175
+
176
+ # --------------------------------------------------------------------------- MCP default client
177
+
178
+ _MCP_PROTOCOL = "2025-03-26"
179
+ _SUBMIT_TIMEOUT = 60
180
+
181
+
182
+ def default_mcp_call(tool: str, timeout: int = 30, **arguments: Any) -> Any:
183
+ """Production McpFn seam: a tiny stdlib streamable-HTTP MCP client (strategy_list +
184
+ strategy_close for close; account_get_portfolio + strategy_create_custom_strategy for fund).
185
+ Creds from ``SENPI_AUTH_TOKEN`` / ``SENPI_API_KEY`` (that
186
+ precedence — the SAME resolution the runtime's scanner-launcher uses,
187
+ src/runtime-api/intake/scanner-launcher.ts:201-205; real boxes set SENPI_AUTH_TOKEN, which
188
+ is all the legacy ops mcp_client.py read) plus ``SENPI_MCP_URL``; a missing cred raises
189
+ :class:`McpCredsMissing` BEFORE any network so ``close`` can teach it as CMP223."""
190
+ import http.client
191
+ import itertools
192
+ import json as _json
193
+ from urllib.parse import urlsplit
194
+
195
+ api_key = (
196
+ os.environ.get("SENPI_AUTH_TOKEN", "").strip()
197
+ or os.environ.get("SENPI_API_KEY", "").strip()
198
+ )
199
+ url = os.environ.get("SENPI_MCP_URL", "").strip() or "https://mcp.prod.senpi.ai/mcp"
200
+ if not api_key:
201
+ raise McpCredsMissing(
202
+ "Neither SENPI_AUTH_TOKEN nor SENPI_API_KEY is set — cannot reach the Senpi "
203
+ "MCP server for strategy_list / strategy_close."
204
+ )
205
+
206
+ ids = itertools.count(1)
207
+
208
+ def _post(body: dict, sid: Optional[str]) -> tuple[int, bytes, str, Optional[str]]:
209
+ parts = urlsplit(url)
210
+ scheme = parts.scheme
211
+ host = parts.hostname or ""
212
+ port = parts.port or (443 if scheme == "https" else 80)
213
+ path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
214
+ data = _json.dumps(body).encode("utf-8")
215
+ hdrs = {
216
+ "Host": parts.netloc,
217
+ "Content-Type": "application/json",
218
+ "Accept": "application/json, text/event-stream",
219
+ "Content-Length": str(len(data)),
220
+ "Authorization": f"Bearer {api_key}",
221
+ }
222
+ if sid:
223
+ hdrs["Mcp-Session-Id"] = sid
224
+ cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
225
+ conn = cls(host, port, timeout=timeout)
226
+ try:
227
+ conn.request("POST", path, body=data, headers=hdrs)
228
+ resp = conn.getresponse()
229
+ raw = resp.read()
230
+ return resp.status, raw, (resp.getheader("Content-Type") or ""), resp.getheader("Mcp-Session-Id")
231
+ finally:
232
+ try:
233
+ conn.close()
234
+ except Exception: # noqa: BLE001
235
+ pass
236
+
237
+ def _parse(raw: bytes, content_type: str) -> dict:
238
+ text = raw.decode("utf-8", errors="replace")
239
+ if "text/event-stream" in content_type.lower():
240
+ for line in text.splitlines():
241
+ line = line.strip()
242
+ if line.startswith("data:"):
243
+ payload = line[5:].strip()
244
+ if payload and payload != "[DONE]":
245
+ try:
246
+ obj = _json.loads(payload)
247
+ if isinstance(obj, dict):
248
+ return obj
249
+ except _json.JSONDecodeError:
250
+ continue
251
+ return {}
252
+ try:
253
+ obj = _json.loads(text) if text else {}
254
+ return obj if isinstance(obj, dict) else {"result": obj}
255
+ except _json.JSONDecodeError:
256
+ return {}
257
+
258
+ def _unwrap(rpc: dict) -> Any:
259
+ if rpc.get("error"):
260
+ err = rpc["error"]
261
+ raise McpError(
262
+ f"JSON-RPC error {err.get('code')}: {err.get('message')}"
263
+ if isinstance(err, dict) else str(err)
264
+ )
265
+ result = rpc.get("result")
266
+ if not isinstance(result, dict):
267
+ return result
268
+ content = result.get("content")
269
+ payload = None
270
+ if isinstance(content, list) and content:
271
+ first = content[0]
272
+ if isinstance(first, dict) and "text" in first:
273
+ payload = first["text"]
274
+ if result.get("isError"):
275
+ raise McpError(f"tool error: {str(payload)[:300]}")
276
+ if payload is not None:
277
+ try:
278
+ doc = _json.loads(payload)
279
+ except (_json.JSONDecodeError, TypeError):
280
+ return result
281
+ if isinstance(doc, dict) and doc.get("success") is False:
282
+ e = doc.get("error") or {}
283
+ raise McpError(
284
+ f"tool failed: {e.get('code')}: {e.get('message')}"
285
+ if isinstance(e, dict) else f"tool failed: {e}"
286
+ )
287
+ return doc
288
+ return result
289
+
290
+ # initialize handshake (streamable-http), then tools/call.
291
+ init = {
292
+ "jsonrpc": "2.0", "id": next(ids), "method": "initialize",
293
+ "params": {"protocolVersion": _MCP_PROTOCOL, "capabilities": {},
294
+ "clientInfo": {"name": "composer", "version": "1.0.0"}},
295
+ }
296
+ status, _raw, _ct, sid = _post(init, None)
297
+ if status >= 400:
298
+ raise McpError(f"initialize HTTP {status}")
299
+ _post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, sid)
300
+
301
+ call = {
302
+ "jsonrpc": "2.0", "id": next(ids), "method": "tools/call",
303
+ "params": {"name": tool, "arguments": arguments},
304
+ }
305
+ status, raw, ct, _sid = _post(call, sid)
306
+ if status >= 400:
307
+ raise McpError(f"{tool} HTTP {status}")
308
+ return _unwrap(_parse(raw, ct))
@@ -0,0 +1,254 @@
1
+ """check — ONE verdict aggregating the whole build gate (D7).
2
+
3
+ ``composer check <graph.yaml>`` collapses everything that must be true before a graph is
4
+ deployable into a single GREEN/RED verdict, run as ordered stages that short-circuit:
5
+
6
+ validate schema + graph-semantic + pure_fn PURITY checks (the teaching validator).
7
+ pure_fn_tests the pure_fn inline fixture tests (CMP05x), reported as their own stage.
8
+ compile deterministic materialization to scan.py on the external_scanner contract.
9
+ smoke import the emitted module + run one scan() tick over a stub ctx (liveness).
10
+ runtime_validate PROTECTION policy (CMP120) + the REAL Zod+DSL oracle over the emitted
11
+ runtime.yaml (CMP1xx). Protection-by-default is enforced HERE: an unmanaged
12
+ unit with no explicit opt-out is RED. The Zod oracle needs a built dist/; when
13
+ absent (CI without `npm run build`) it is noted SKIPPED, not RED — the policy
14
+ layer still gates. Opt-out (`unmanaged=true`) is GREEN with a loud warning.
15
+
16
+ The validator runs purity AND the inline fixture tests in one pass, so rather than re-run
17
+ them we PARTITION that single run's errors: CMP05x (inline-test) findings become the
18
+ ``pure_fn_tests`` stage; everything else is the ``validate`` stage. A stage is SKIPPED once
19
+ an earlier stage is RED (compiling an invalid graph, or smoking a graph that won't compile,
20
+ would only produce noise). The verdict is GREEN iff every stage is GREEN.
21
+
22
+ The result is both human-rendered and ``--json`` (verdict + per-stage status + located
23
+ errors). ``deploy`` consumes this object directly as its hard gate; the compiled source is
24
+ carried on the result so ``deploy`` need not recompile.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ from composer.compiler import CompileError, compile_text
34
+ from composer.validator import validate_text
35
+ from composer.validator.errors import TeachingError
36
+ from composer.validator.loader import load_graph
37
+
38
+ from .runtime_validate import check_runtime_unit
39
+ from .smoke import SmokeError, run_smoke
40
+
41
+ # Inline pure_fn fixture-test error codes (partitioned into their own stage).
42
+ _PURE_FN_TEST_CODES = {"CMP050", "CMP051", "CMP052"}
43
+
44
+ GREEN = "GREEN"
45
+ RED = "RED"
46
+ SKIPPED = "SKIPPED"
47
+
48
+
49
+ @dataclass
50
+ class Stage:
51
+ name: str
52
+ status: str = SKIPPED
53
+ errors: list[TeachingError] = field(default_factory=list)
54
+ # free-text findings for stages that don't produce TeachingErrors (compile / smoke)
55
+ messages: list[str] = field(default_factory=list)
56
+ detail: Optional[dict] = None
57
+
58
+ def to_json(self) -> dict:
59
+ out: dict = {
60
+ "stage": self.name,
61
+ "status": self.status,
62
+ "errors": [e.to_dict() for e in self.errors],
63
+ }
64
+ if self.messages:
65
+ out["messages"] = list(self.messages)
66
+ if self.detail is not None:
67
+ out["detail"] = self.detail
68
+ return out
69
+
70
+
71
+ @dataclass
72
+ class CheckResult:
73
+ source: str
74
+ stages: list[Stage]
75
+ scan_source: Optional[str] = None # the emitted scan.py when compile succeeded
76
+ runtime_source: Optional[str] = None # the emitted runtime.yaml when runtime_validate ran
77
+
78
+ @property
79
+ def verdict(self) -> str:
80
+ return GREEN if all(s.status == GREEN for s in self.stages) else RED
81
+
82
+ @property
83
+ def ok(self) -> bool:
84
+ return self.verdict == GREEN
85
+
86
+ def to_json(self) -> dict:
87
+ errors = [e.to_dict() for s in self.stages for e in s.errors]
88
+ return {
89
+ "verb": "check",
90
+ "graph": self.source,
91
+ "verdict": self.verdict,
92
+ "stages": [s.to_json() for s in self.stages],
93
+ "error_count": len(errors),
94
+ "errors": errors,
95
+ }
96
+
97
+ def render(self) -> str:
98
+ lines = [f"{self.verdict}: {self.source}"]
99
+ for s in self.stages:
100
+ mark = {"GREEN": "ok ", "RED": "FAIL", "SKIPPED": "skip"}[s.status]
101
+ lines.append(f" [{mark}] {s.name}")
102
+ for e in sorted(s.errors, key=lambda e: e.sort_key):
103
+ lines.append(" " + e.render().replace("\n", "\n "))
104
+ for m in s.messages:
105
+ lines.append(f" {m}")
106
+ lines.append("")
107
+ lines.append(
108
+ "check GREEN — ready to deploy." if self.ok
109
+ else "check RED — fix the failing stage(s); deploy is refused until GREEN."
110
+ )
111
+ return "\n".join(lines) + "\n"
112
+
113
+
114
+ def run_check(source_text: str, source_name: str) -> CheckResult:
115
+ """Run the whole gate over graph YAML text; return the aggregated verdict."""
116
+ validate_stage = Stage("validate")
117
+ pure_fn_stage = Stage("pure_fn_tests")
118
+ compile_stage = Stage("compile")
119
+ smoke_stage = Stage("smoke")
120
+ runtime_stage = Stage("runtime_validate")
121
+ stages = [validate_stage, pure_fn_stage, compile_stage, smoke_stage, runtime_stage]
122
+
123
+ # --- validate (+ partition the pure_fn inline-test findings into their own stage) ---
124
+ vresult = validate_text(source_text, source=source_name)
125
+ for err in vresult.errors:
126
+ (pure_fn_stage if err.code in _PURE_FN_TEST_CODES else validate_stage).errors.append(err)
127
+ validate_stage.status = RED if validate_stage.errors else GREEN
128
+ pure_fn_stage.status = RED if pure_fn_stage.errors else GREEN
129
+
130
+ if not vresult.ok:
131
+ # compile/smoke stay SKIPPED — nothing valid to materialize.
132
+ return CheckResult(source_name, stages)
133
+
134
+ # Escape-hatch annotations (non-blocking, greppable): surface every mcp_read/raw use so the
135
+ # codification feedback loop is visible. Attached to the (GREEN) validate stage as messages,
136
+ # mirroring how runtime_validate surfaces the wallet-binding acknowledgment.
137
+ validate_stage.messages.extend(_hatch_annotations(source_text))
138
+
139
+ # --- compile ---
140
+ scan_source: Optional[str] = None
141
+ try:
142
+ scan_source = compile_text(source_text, source_name=source_name)
143
+ compile_stage.status = GREEN
144
+ except CompileError as exc:
145
+ compile_stage.status = RED
146
+ compile_stage.messages.append(str(exc).splitlines()[0])
147
+ return CheckResult(source_name, stages, scan_source=None)
148
+
149
+ # --- smoke (import + one tick over a stub ctx) ---
150
+ try:
151
+ detail = run_smoke(scan_source, scanner_name=source_name)
152
+ smoke_stage.status = GREEN
153
+ smoke_stage.detail = detail
154
+ except SmokeError as exc:
155
+ smoke_stage.status = RED
156
+ smoke_stage.messages.append(str(exc))
157
+ return CheckResult(source_name, stages, scan_source=scan_source)
158
+
159
+ # --- runtime_validate (protection policy + the real Zod+DSL oracle over runtime.yaml) ---
160
+ runtime_source = _run_runtime_validate(source_text, runtime_stage)
161
+
162
+ return CheckResult(source_name, stages, scan_source=scan_source, runtime_source=runtime_source)
163
+
164
+
165
+ def _hatch_annotations(source_text: str) -> list[str]:
166
+ """The non-blocking escape-hatch annotation block: one line per mcp_read use (tool +
167
+ consuming pure_fn), plus a 'prefer the first-class node' note when the tool is already
168
+ wrapped by a curated source. Only called on a graph that already validated GREEN, so every
169
+ raw consumer is a pure_fn. Empty (and thus silent) for graphs that use no hatch."""
170
+ from composer.registry import NODES
171
+
172
+ try:
173
+ doc, _ = load_graph(source_text)
174
+ except Exception: # noqa: BLE001 — a parse failure here is already reported by validate
175
+ return []
176
+ nodes = doc.get("nodes") or []
177
+ edges = doc.get("edges") or []
178
+
179
+ read_tool: dict[str, str] = {}
180
+ for n in nodes:
181
+ if isinstance(n, dict) and n.get("node") == "mcp_read" and isinstance(n.get("id"), str):
182
+ w = n.get("with") if isinstance(n.get("with"), dict) else {}
183
+ tool = w.get("tool")
184
+ read_tool[n["id"]] = tool if isinstance(tool, str) and tool.strip() else "?"
185
+ if not read_tool:
186
+ return []
187
+
188
+ wrapped: dict[str, str] = {}
189
+ for nd in NODES:
190
+ if nd.mcp_tool and nd.mcp_tool not in wrapped:
191
+ wrapped[nd.mcp_tool] = nd.name
192
+
193
+ consumers: dict[str, set[str]] = {rid: set() for rid in read_tool}
194
+ for e in edges:
195
+ if not isinstance(e, dict):
196
+ continue
197
+ frm, to = e.get("from"), e.get("to")
198
+ if not (isinstance(frm, str) and isinstance(to, str)):
199
+ continue
200
+ fid = frm.split(".", 1)[0]
201
+ if fid in consumers:
202
+ consumers[fid].add(to.split(".", 1)[0])
203
+
204
+ lines = [
205
+ "escape-hatch usage (non-blocking; prefer a first-class node where one exists):",
206
+ ]
207
+ for rid in sorted(read_tool):
208
+ tool = read_tool[rid]
209
+ cons = ", ".join(f"`{c}`" for c in sorted(consumers[rid])) or "(none wired)"
210
+ lines.append(f" - mcp_read {tool!r} (node {rid}) -> normalized by pure_fn {cons}")
211
+ if tool in wrapped:
212
+ lines.append(
213
+ f" note: {tool!r} has a first-class node `{wrapped[tool]}` — prefer it "
214
+ f"unless you need fields it doesn't expose"
215
+ )
216
+ return lines
217
+
218
+
219
+ def _run_runtime_validate(source_text: str, stage: Stage) -> Optional[str]:
220
+ """Emit runtime.yaml from the graph's metadata, enforce PROTECTION policy (CMP120), and run
221
+ the real Zod+DSL oracle. Records status/errors/messages on ``stage``; returns the emitted
222
+ runtime.yaml text (so deploy can stage it without re-emitting)."""
223
+ from composer.authoring.runtime_emit import answers_from_metadata, render_runtime_yaml
224
+
225
+ try:
226
+ doc, _ = load_graph(source_text)
227
+ answers = answers_from_metadata(doc.get("metadata") or {})
228
+ except Exception as exc: # noqa: BLE001 — a bad metadata block is a graph defect, reported here
229
+ stage.status = RED
230
+ stage.messages.append(f"could not derive runtime answers from metadata: {exc}")
231
+ return None
232
+
233
+ runtime_source = render_runtime_yaml(answers)
234
+ oracle, policy_errors = check_runtime_unit(answers)
235
+ stage.errors.extend(policy_errors)
236
+ stage.errors.extend(oracle.errors)
237
+
238
+ if answers.unmanaged:
239
+ stage.messages.append(
240
+ "WARNING: unmanaged=true — this unit ships with NO exit protection (no stop-loss, "
241
+ "no trailing floor, no time-cut). Positions can run to liquidation. Opt-out accepted."
242
+ )
243
+ if oracle.skipped and oracle.skip_reason:
244
+ stage.messages.append(oracle.skip_reason)
245
+ for info in oracle.info_messages:
246
+ stage.messages.append(info)
247
+
248
+ stage.status = RED if stage.errors else GREEN
249
+ return runtime_source
250
+
251
+
252
+ def check_file(path) -> CheckResult:
253
+ p = Path(path)
254
+ return run_check(p.read_text(encoding="utf-8"), p.name)