@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.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- 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)
|