@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,586 @@
|
|
|
1
|
+
"""install — bind a staged unit onto a box and hand it to the frozen consumer (slice A2).
|
|
2
|
+
|
|
3
|
+
``composer deploy`` STAGES a complete, standalone, pristine unit (placeholder wallet intact).
|
|
4
|
+
``composer install <target> --wallet 0x…`` is the step that actually puts one instance of that
|
|
5
|
+
unit onto THIS box and starts it, without changing anything the runtime/gateway already does
|
|
6
|
+
(the ``openclaw senpi runtime create`` path is FROZEN — this verb only feeds it).
|
|
7
|
+
|
|
8
|
+
Five steps, each derivable from disk afterwards (resumable-by-artifact; no hidden state):
|
|
9
|
+
|
|
10
|
+
(a) PREFLIGHT — resolve the staged tree, verify every staged artifact's bytes against
|
|
11
|
+
``manifest.json`` (tamper/hand-edit detection, CMP201), and confirm the recorded oracle
|
|
12
|
+
verdict is GREEN (VALID; a SKIPPED verdict from an oracle-less box is allowed, INVALID is
|
|
13
|
+
CMP202). "GREEN means installable" — this is the install-time reuse of the deploy oracle.
|
|
14
|
+
|
|
15
|
+
(b) COPY to the CANONICAL, CONTENT-ADDRESSED, IMMUTABLE dir
|
|
16
|
+
``<state>/strategies/<strategy>/<hash8>/`` — write to a temp sibling, verify every copied
|
|
17
|
+
file against the manifest hash (CMP203), then atomic-rename into place. The dir is NEVER
|
|
18
|
+
edited after it lands, so the frozen inline-YAML in the registry can never drift from the
|
|
19
|
+
scan.py re-read off disk on a scaffold respawn. ``hash8`` = the content identity of the
|
|
20
|
+
PRISTINE staged unit (see :func:`compute_hash8`), so identical content always maps to the
|
|
21
|
+
same dir and a re-install is an idempotent no-op.
|
|
22
|
+
|
|
23
|
+
(c) WALLET BIND — substitute the real ``--wallet`` for the ``${..._WALLET}`` placeholder in the
|
|
24
|
+
COPIED ``main/runtime.yaml`` ONLY (the staged source stays pristine, placeholder intact),
|
|
25
|
+
record the binding in ``install.json`` beside the copied tree, and re-run the placeholder
|
|
26
|
+
policy on the bound YAML: after binding, ANY remaining bare ``${VAR}`` is an error (CMP115).
|
|
27
|
+
|
|
28
|
+
(d) INVOKE the frozen consumer through an INJECTABLE SEAM (``create_fn``): in production it
|
|
29
|
+
shells out to ``openclaw senpi runtime create -p <hashdir>/main/runtime.yaml`` (PATH mode —
|
|
30
|
+
never ``--content``, which would drop the scanner dir the external scanner needs); tests
|
|
31
|
+
fake it.
|
|
32
|
+
|
|
33
|
+
(e) VERIFY + REPORT — map the known installRuntime failure classes (duplicate id,
|
|
34
|
+
one-runtime-per-wallet, wallet not ACTIVE, YAML invalid, entrypoint missing) to
|
|
35
|
+
teaching-shaped errors telling the user exactly what to do next (CMP210-219).
|
|
36
|
+
|
|
37
|
+
Scope (phase 1): the agent supplies ``--wallet``; wallet creation/funding is OUT. The
|
|
38
|
+
update/reinstall flow (delete + create) is OUT beyond the teaching error that names the delete
|
|
39
|
+
command. No wall-clock is baked in: the binding timestamp is an injected value (``bound_at``).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import uuid
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Callable, Optional
|
|
53
|
+
|
|
54
|
+
from composer.validator.errors import Location, TeachingError
|
|
55
|
+
from composer.validator.loader import load_graph
|
|
56
|
+
|
|
57
|
+
from . import _box
|
|
58
|
+
from ._box import McpFn
|
|
59
|
+
from .runtime_validate import bare_env_placeholders
|
|
60
|
+
|
|
61
|
+
INSTALLED = "INSTALLED"
|
|
62
|
+
ALREADY_INSTALLED = "ALREADY_INSTALLED"
|
|
63
|
+
REFUSED = "REFUSED"
|
|
64
|
+
FAILED = "FAILED"
|
|
65
|
+
|
|
66
|
+
INSTALL_JSON = "install.json"
|
|
67
|
+
REGISTRY_FILENAME = "installed_runtimes.json"
|
|
68
|
+
|
|
69
|
+
# The ops-bound placeholder the composer emits (exactly one, at strategy.wallet). Mirrors
|
|
70
|
+
# composer/authoring/runtime_emit.py::_env_wallet — ${<SLUG>_WALLET}.
|
|
71
|
+
_WALLET_TOKEN = re.compile(r"\$\{[A-Za-z0-9_]+_WALLET\}")
|
|
72
|
+
# A 0x… EVM address (light format gate; ownership is the gateway's job, best-effort).
|
|
73
|
+
_WALLET_ADDR = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- injectable seam
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CreateResult:
|
|
80
|
+
"""What the frozen ``openclaw senpi runtime create`` consumer returned. The seam yields
|
|
81
|
+
this so the mapper reads exit code + text without knowing HOW it was produced."""
|
|
82
|
+
|
|
83
|
+
returncode: int
|
|
84
|
+
stdout: str = ""
|
|
85
|
+
stderr: str = ""
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def ok(self) -> bool:
|
|
89
|
+
return self.returncode == 0
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def text(self) -> str:
|
|
93
|
+
return f"{self.stdout}\n{self.stderr}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
CreateFn = Callable[[str, str], CreateResult]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _default_create(runtime_yaml_path: str, runtime_id: str) -> CreateResult:
|
|
100
|
+
"""Production seam: shell out to the FROZEN consumer in PATH mode (never --content — the
|
|
101
|
+
external scanner needs the recipe dir the -p path carries; index.ts:1088, senpi-commands.ts:94).
|
|
102
|
+
Captures stdout/stderr so the verdict is parsed truthfully rather than inherited to the tty."""
|
|
103
|
+
openclaw = os.environ.get("OPENCLAW_BIN", "openclaw")
|
|
104
|
+
proc = subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
105
|
+
[openclaw, "senpi", "runtime", "create", "-p", runtime_yaml_path, "--runtime-id", runtime_id],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
)
|
|
109
|
+
return CreateResult(proc.returncode, proc.stdout or "", proc.stderr or "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- content identity
|
|
113
|
+
|
|
114
|
+
def compute_hash8(manifest: dict) -> str:
|
|
115
|
+
"""The content identity of a PRISTINE staged unit: the first 8 hex chars of a sha256 over the
|
|
116
|
+
manifest ``artifacts`` map (sorted ``relpath\\0filesha\\n`` — the same stable aggregation
|
|
117
|
+
deploy uses for ``manifest.primitives.sha256``, but over EVERY artifact).
|
|
118
|
+
|
|
119
|
+
Deterministic and wallet-independent: the staged runtime.yaml carries the ``${..._WALLET}``
|
|
120
|
+
PLACEHOLDER, not a wallet, so authoring the same unit always yields the same hash8 → the same
|
|
121
|
+
immutable on-box dir → an idempotent re-install. Binding a wallet mutates only the COPY, never
|
|
122
|
+
this identity."""
|
|
123
|
+
import hashlib
|
|
124
|
+
|
|
125
|
+
artifacts = manifest.get("artifacts") or {}
|
|
126
|
+
h = hashlib.sha256()
|
|
127
|
+
for rel in sorted(artifacts):
|
|
128
|
+
h.update(rel.encode("utf-8"))
|
|
129
|
+
h.update(b"\0")
|
|
130
|
+
h.update(str(artifacts[rel]).encode("utf-8"))
|
|
131
|
+
h.update(b"\n")
|
|
132
|
+
return h.hexdigest()[:8]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sha256_file(path: Path) -> str:
|
|
136
|
+
import hashlib
|
|
137
|
+
|
|
138
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def derive_runtime_id(strategy: str, hash8: str) -> str:
|
|
142
|
+
"""``<strategy>-<hash8>`` sanitized to the registry's id charset (mirrors
|
|
143
|
+
strategy-registry.ts::sanitizeId: [A-Za-z0-9_-], collapse repeats, ≤64).
|
|
144
|
+
|
|
145
|
+
Content-keyed on purpose: the SAME staged content always derives the SAME id, so a duplicate
|
|
146
|
+
is meaningful ("this exact strategy version is already installed") and the whole verb is
|
|
147
|
+
resumable-by-artifact. installRuntime re-sanitizes, so this is already its final form."""
|
|
148
|
+
raw = f"{strategy}-{hash8}"
|
|
149
|
+
out = re.sub(r"[^A-Za-z0-9_-]", "_", raw)
|
|
150
|
+
out = re.sub(r"_+", "_", out).lstrip("_")
|
|
151
|
+
return (out or "runtime")[:64]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------- result
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class InstallResult:
|
|
158
|
+
target: str
|
|
159
|
+
verdict: str
|
|
160
|
+
wallet: str
|
|
161
|
+
hash8: Optional[str] = None
|
|
162
|
+
runtime_id: Optional[str] = None
|
|
163
|
+
canonical_dir: Optional[str] = None
|
|
164
|
+
errors: list[TeachingError] = field(default_factory=list)
|
|
165
|
+
message: str = ""
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def ok(self) -> bool:
|
|
169
|
+
return self.verdict in (INSTALLED, ALREADY_INSTALLED)
|
|
170
|
+
|
|
171
|
+
def to_json(self) -> dict:
|
|
172
|
+
return {
|
|
173
|
+
"verb": "install",
|
|
174
|
+
"target": self.target,
|
|
175
|
+
"verdict": self.verdict,
|
|
176
|
+
"wallet": self.wallet,
|
|
177
|
+
"hash8": self.hash8,
|
|
178
|
+
"runtime_id": self.runtime_id,
|
|
179
|
+
"canonical_dir": self.canonical_dir,
|
|
180
|
+
"error_count": len(self.errors),
|
|
181
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
182
|
+
"message": self.message,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
def render(self) -> str:
|
|
186
|
+
head = f"{self.verdict}: {self.target}"
|
|
187
|
+
lines = [head]
|
|
188
|
+
if self.hash8:
|
|
189
|
+
lines.append(f" hash8 : {self.hash8}")
|
|
190
|
+
if self.runtime_id:
|
|
191
|
+
lines.append(f" runtime id : {self.runtime_id}")
|
|
192
|
+
if self.canonical_dir:
|
|
193
|
+
lines.append(f" on-box dir : {self.canonical_dir}")
|
|
194
|
+
if self.wallet:
|
|
195
|
+
lines.append(f" wallet : {self.wallet}")
|
|
196
|
+
if self.message:
|
|
197
|
+
lines.append(f" {self.message}")
|
|
198
|
+
for e in self.errors:
|
|
199
|
+
lines.append(e.render())
|
|
200
|
+
return "\n".join(lines) + "\n"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _refused(target: str, wallet: str, error: TeachingError, **kw) -> InstallResult:
|
|
204
|
+
return InstallResult(target=target, verdict=REFUSED, wallet=wallet, errors=[error],
|
|
205
|
+
message=error.message, **kw)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------- target resolution
|
|
209
|
+
|
|
210
|
+
def _resolve_staged(target: str, out_dir: Optional[str]) -> Optional[Path]:
|
|
211
|
+
"""Resolve ``target`` into the staged tree dir (the dir containing ``manifest.json``).
|
|
212
|
+
|
|
213
|
+
Accepts either the staged package dir directly, OR a graph source file plus ``--out`` — then
|
|
214
|
+
the staged tree is ``<out>/<strategy>`` (matching deploy's layout). Returns None if no staged
|
|
215
|
+
manifest is found (CMP200)."""
|
|
216
|
+
p = Path(target)
|
|
217
|
+
if p.is_dir() and (p / "manifest.json").is_file():
|
|
218
|
+
return p
|
|
219
|
+
if p.is_file() and out_dir is not None:
|
|
220
|
+
try:
|
|
221
|
+
doc, _ = load_graph(p.read_text(encoding="utf-8"))
|
|
222
|
+
strategy = str((doc.get("metadata") or {}).get("strategy")
|
|
223
|
+
or p.stem.replace(".graph", ""))
|
|
224
|
+
except Exception: # noqa: BLE001
|
|
225
|
+
strategy = p.stem.replace(".graph", "")
|
|
226
|
+
candidate = Path(out_dir) / strategy
|
|
227
|
+
if (candidate / "manifest.json").is_file():
|
|
228
|
+
return candidate
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _auto_bind_wallet(target: str, out_dir: Optional[str], base_state_dir: str,
|
|
233
|
+
mcp_fn: McpFn) -> tuple[str, Optional[TeachingError]]:
|
|
234
|
+
"""Resolve a wallet WITHOUT an explicit ``--wallet``: read the strategy's ``wallet.json``
|
|
235
|
+
(written by ``composer fund``) and bind it ONLY if MCP confirms the strategy is ACTIVE.
|
|
236
|
+
|
|
237
|
+
Returns ``(wallet, None)`` on a confirmed ACTIVE wallet, or ``("", TeachingError)`` otherwise
|
|
238
|
+
(CMP206 for missing / pending / stale / unverifiable; CMP223 when MCP creds are absent)."""
|
|
239
|
+
# Imported lazily to avoid an import cycle (fund/close import install at module load).
|
|
240
|
+
from ._box import LIVE_STATUSES, McpCredsMissing, McpError
|
|
241
|
+
from .close import _find_list, _strat_id, _strat_status, _strat_wallet
|
|
242
|
+
from .fund import _match_strategy, _read_wallet_json, wallet_json_path
|
|
243
|
+
|
|
244
|
+
def _cmp206(msg: str, fix: str) -> tuple[str, TeachingError]:
|
|
245
|
+
return "", TeachingError("CMP206", msg, fix, Location())
|
|
246
|
+
|
|
247
|
+
staged = _resolve_staged(target, out_dir)
|
|
248
|
+
if staged is None:
|
|
249
|
+
return _cmp206(
|
|
250
|
+
"`--wallet` was omitted and the target is not a staged unit (no manifest.json found).",
|
|
251
|
+
"Pass `--wallet 0x…`, or point at a `composer deploy` staging dir (plus `-o` if the "
|
|
252
|
+
"target is a graph file) so the strategy's wallet.json can be located.",
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
manifest = json.loads((staged / "manifest.json").read_text(encoding="utf-8"))
|
|
256
|
+
except (OSError, json.JSONDecodeError):
|
|
257
|
+
manifest = {}
|
|
258
|
+
strategy = str(manifest.get("strategy") or staged.name)
|
|
259
|
+
wj = wallet_json_path(base_state_dir, strategy)
|
|
260
|
+
rec = _read_wallet_json(wj)
|
|
261
|
+
if rec is None:
|
|
262
|
+
return _cmp206(
|
|
263
|
+
f"`--wallet` was omitted and no funded wallet.json exists for {strategy!r}.",
|
|
264
|
+
f"Run `composer fund <target> --budget N` first (it creates + funds the wallet and "
|
|
265
|
+
f"writes {wj}), then re-run `composer install` to auto-bind it — or pass `--wallet 0x…` "
|
|
266
|
+
f"explicitly.",
|
|
267
|
+
)
|
|
268
|
+
rec_wallet = str(rec.get("wallet") or "")
|
|
269
|
+
rec_sid = rec.get("strategyId")
|
|
270
|
+
try:
|
|
271
|
+
strategies = _find_list(mcp_fn("strategy_list", status=LIVE_STATUSES), "strategies")
|
|
272
|
+
except McpCredsMissing as e:
|
|
273
|
+
return "", TeachingError(
|
|
274
|
+
"CMP223", f"MCP credentials missing: {e}",
|
|
275
|
+
"Set SENPI_AUTH_TOKEN or SENPI_API_KEY (and optionally SENPI_MCP_URL) — auto-binding "
|
|
276
|
+
"a wallet.json requires MCP to confirm the strategy is ACTIVE. Or pass `--wallet 0x…`.",
|
|
277
|
+
Location(),
|
|
278
|
+
)
|
|
279
|
+
except McpError:
|
|
280
|
+
return _cmp206(
|
|
281
|
+
f"`--wallet` was omitted; could not reach MCP to confirm {strategy!r} is ACTIVE.",
|
|
282
|
+
"Re-run once MCP is reachable, or pass `--wallet 0x…` explicitly.",
|
|
283
|
+
)
|
|
284
|
+
s = _match_strategy(strategies, rec_sid, rec_wallet)
|
|
285
|
+
status = str(_strat_status(s) or "").upper() if s else ""
|
|
286
|
+
addr = str(_strat_wallet(s) or "") if s else ""
|
|
287
|
+
if status == "ACTIVE" and (addr or rec_wallet):
|
|
288
|
+
return (addr or rec_wallet), None
|
|
289
|
+
if s is not None and status:
|
|
290
|
+
return _cmp206(
|
|
291
|
+
f"the wallet.json for {strategy!r} exists but its strategy is not ACTIVE (status "
|
|
292
|
+
f"{status}).",
|
|
293
|
+
"Run `composer fund <target> --budget N` to poll it to ACTIVE (idempotent), then "
|
|
294
|
+
"re-run `composer install`. Explicit `--wallet 0x…` bypasses this check.",
|
|
295
|
+
)
|
|
296
|
+
return _cmp206(
|
|
297
|
+
f"the wallet.json for {strategy!r} is stale — its strategy is not in your active set.",
|
|
298
|
+
f"It was likely closed. Delete the stale marker `{wj}` and run `composer fund <target> "
|
|
299
|
+
f"--budget N` for a fresh wallet, then re-run `composer install`.",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --------------------------------------------------------------------------- installRuntime mapper
|
|
304
|
+
|
|
305
|
+
def _map_create_failure(res: CreateResult) -> TeachingError:
|
|
306
|
+
"""Map a FROZEN-consumer failure to a teaching error by matching the stable substrings the
|
|
307
|
+
installRuntime RPC emits (src/index.ts:1074-1347). Order matters — the most specific class
|
|
308
|
+
first — so a generic fallback (CMP219) only catches genuinely unrecognised failures."""
|
|
309
|
+
t = res.text
|
|
310
|
+
if "already exists" in t and "runtime with id" in t:
|
|
311
|
+
return TeachingError(
|
|
312
|
+
"CMP210", "a runtime with this id already exists on the box.",
|
|
313
|
+
"This exact strategy version is already installed. Tear it down first with "
|
|
314
|
+
"`composer close <strategy>` (the sanctioned path — it stops the runtime, flattens "
|
|
315
|
+
"positions, and returns funds), then re-install. The update/reinstall flow is out of "
|
|
316
|
+
"phase-1 scope.",
|
|
317
|
+
Location(),
|
|
318
|
+
)
|
|
319
|
+
if "is already running" in t:
|
|
320
|
+
return TeachingError(
|
|
321
|
+
"CMP211", "a runtime for this wallet is already running (one running runtime per wallet).",
|
|
322
|
+
"The box allows ONE running runtime per wallet. Tear the existing one down with "
|
|
323
|
+
"`composer close <strategy>` (stops the runtime, flattens positions, returns funds), "
|
|
324
|
+
"then re-install. Its id is in the message above and in `openclaw senpi runtime list`.",
|
|
325
|
+
Location(),
|
|
326
|
+
)
|
|
327
|
+
if "was not found in your Senpi account" in t or "requires ACTIVE status" in t or "has status" in t:
|
|
328
|
+
return TeachingError(
|
|
329
|
+
"CMP212", "the strategy wallet is not ACTIVE (or not found in your Senpi account).",
|
|
330
|
+
"A runtime can only bind an ACTIVE strategy wallet. Call the MCP tool `strategy_list` "
|
|
331
|
+
"and use a wallet whose status is ACTIVE for `--wallet`; if it is still initializing, "
|
|
332
|
+
"wait for it to reach ACTIVE and retry.",
|
|
333
|
+
Location(),
|
|
334
|
+
)
|
|
335
|
+
if "entrypoint not found" in t:
|
|
336
|
+
return TeachingError(
|
|
337
|
+
"CMP214", "the external-scanner entrypoint (scan.py) was not found on disk.",
|
|
338
|
+
"The canonical hash dir is missing its scan module. This should not happen after a "
|
|
339
|
+
"verified copy — re-run `composer install` (the copy is re-verified against the "
|
|
340
|
+
"manifest), and if it persists re-`composer deploy` the graph.",
|
|
341
|
+
Location(),
|
|
342
|
+
)
|
|
343
|
+
if "invalid or incomplete YAML" in t or "Could not start the runtime" in t:
|
|
344
|
+
return TeachingError(
|
|
345
|
+
"CMP213", "the runtime YAML was invalid/incomplete at load.",
|
|
346
|
+
"The gateway rejected the runtime.yaml at load. Re-run `composer check <graph>` (its "
|
|
347
|
+
"runtime oracle mirrors the loader) and re-`composer deploy`; do not hand-edit the "
|
|
348
|
+
"staged or copied YAML.",
|
|
349
|
+
Location(),
|
|
350
|
+
)
|
|
351
|
+
detail = (res.stderr or res.stdout or "").strip()[:300]
|
|
352
|
+
return TeachingError(
|
|
353
|
+
"CMP219", f"`openclaw senpi runtime create` failed (exit {res.returncode}).",
|
|
354
|
+
"The frozen install consumer failed for an unrecognised reason. Check its output"
|
|
355
|
+
+ (f": {detail}" if detail else "") + ". Verify the box is provisioned "
|
|
356
|
+
"(`openclaw senpi runtime list` works) and retry.",
|
|
357
|
+
Location(),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --------------------------------------------------------------------------- the verb
|
|
362
|
+
|
|
363
|
+
def run_install(
|
|
364
|
+
target: str,
|
|
365
|
+
wallet: Optional[str],
|
|
366
|
+
base_state_dir: str,
|
|
367
|
+
*,
|
|
368
|
+
out_dir: Optional[str] = None,
|
|
369
|
+
runtime_id: Optional[str] = None,
|
|
370
|
+
bound_at: Optional[str] = None,
|
|
371
|
+
create_fn: CreateFn = _default_create,
|
|
372
|
+
mcp_fn: McpFn = _box.default_mcp_call,
|
|
373
|
+
) -> InstallResult:
|
|
374
|
+
"""Install one instance of a staged unit onto this box (see module docstring)."""
|
|
375
|
+
wallet = (wallet or "").strip()
|
|
376
|
+
auto_bound = False
|
|
377
|
+
|
|
378
|
+
# (a0) WALLET RESOLUTION. Explicit `--wallet` ALWAYS wins. When it is omitted, try to
|
|
379
|
+
# auto-bind the strategy's funded wallet from `<state>/strategies/<strategy>/wallet.json`
|
|
380
|
+
# (written by `composer fund`) — but ONLY if MCP confirms that strategy is ACTIVE.
|
|
381
|
+
if not wallet:
|
|
382
|
+
resolved, err = _auto_bind_wallet(target, out_dir, base_state_dir, mcp_fn)
|
|
383
|
+
if err is not None:
|
|
384
|
+
return _refused(target, "", err)
|
|
385
|
+
wallet, auto_bound = resolved, True
|
|
386
|
+
|
|
387
|
+
# (a0) --wallet gate — a light 0x… format check; ownership/ACTIVE is the gateway's job.
|
|
388
|
+
if not _WALLET_ADDR.match(wallet):
|
|
389
|
+
return _refused(target, wallet, TeachingError(
|
|
390
|
+
"CMP205", "`--wallet` is missing or not a 0x… address.",
|
|
391
|
+
"Pass the strategy's ACTIVE wallet: `composer install <staged-dir> --wallet 0x…` "
|
|
392
|
+
"(40 hex chars), or run `composer fund <target> --budget N` first and omit `--wallet` "
|
|
393
|
+
"to auto-bind the funded wallet. Get an address from MCP `strategy_list` "
|
|
394
|
+
"(strategyWalletAddress).",
|
|
395
|
+
Location(),
|
|
396
|
+
))
|
|
397
|
+
|
|
398
|
+
# (a) PREFLIGHT — staged tree exists.
|
|
399
|
+
staged = _resolve_staged(target, out_dir)
|
|
400
|
+
if staged is None:
|
|
401
|
+
return _refused(target, wallet, TeachingError(
|
|
402
|
+
"CMP200", "the install target is not a staged unit (no manifest.json found).",
|
|
403
|
+
"Point `composer install` at a `composer deploy` staging dir (the one containing "
|
|
404
|
+
"manifest.json + main/), or pass the graph file plus `-o <staging-dir>`. Run "
|
|
405
|
+
"`composer deploy <graph> -o <dir>` first.",
|
|
406
|
+
Location(),
|
|
407
|
+
))
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
manifest = json.loads((staged / "manifest.json").read_text(encoding="utf-8"))
|
|
411
|
+
except (OSError, json.JSONDecodeError):
|
|
412
|
+
return _refused(target, wallet, TeachingError(
|
|
413
|
+
"CMP200", "the staged manifest.json could not be read/parsed.",
|
|
414
|
+
"Re-run `composer deploy <graph> -o <dir>` to regenerate a valid staged unit.",
|
|
415
|
+
Location(),
|
|
416
|
+
))
|
|
417
|
+
|
|
418
|
+
# (a) PREFLIGHT — every staged artifact's bytes match its manifest hash (tamper/hand-edit).
|
|
419
|
+
artifacts: dict = manifest.get("artifacts") or {}
|
|
420
|
+
mismatched = _verify_tree(staged, artifacts)
|
|
421
|
+
if mismatched:
|
|
422
|
+
return _refused(target, wallet, TeachingError(
|
|
423
|
+
"CMP201", f"staged artifact(s) do not match manifest.json: {', '.join(mismatched)}.",
|
|
424
|
+
"The staging dir was corrupted or hand-edited after deploy. Never edit a staged unit "
|
|
425
|
+
"in place — re-run `composer deploy <graph> -o <dir>` to restage from the graph.",
|
|
426
|
+
Location(),
|
|
427
|
+
))
|
|
428
|
+
|
|
429
|
+
# (a) PREFLIGHT — the recorded oracle verdict must be GREEN. VALID = green; SKIPPED = allowed
|
|
430
|
+
# (oracle-less box, mirrors deploy/check's own skip-is-not-red policy); INVALID is blocked.
|
|
431
|
+
zod_verdict = str((manifest.get("runtime") or {}).get("zod_verdict") or "")
|
|
432
|
+
if zod_verdict == "INVALID":
|
|
433
|
+
return _refused(target, wallet, TeachingError(
|
|
434
|
+
"CMP202", "the staged unit's recorded oracle verdict is INVALID — it will not install.",
|
|
435
|
+
"The runtime.yaml failed the Zod/DSL oracle at deploy. Fix the graph, re-run "
|
|
436
|
+
"`composer check <graph>` until GREEN, then `composer deploy` before installing.",
|
|
437
|
+
Location(),
|
|
438
|
+
))
|
|
439
|
+
|
|
440
|
+
strategy = str(manifest.get("strategy") or staged.name)
|
|
441
|
+
hash8 = compute_hash8(manifest)
|
|
442
|
+
rid = runtime_id or derive_runtime_id(strategy, hash8)
|
|
443
|
+
canonical = Path(base_state_dir) / "strategies" / strategy / hash8
|
|
444
|
+
installed_marker = _registry_has(Path(base_state_dir), rid)
|
|
445
|
+
|
|
446
|
+
# (b) COPY into the canonical, content-addressed, immutable dir (idempotent).
|
|
447
|
+
if canonical.exists():
|
|
448
|
+
# Same content dir already present — check it is bound to THIS wallet (CMP204 otherwise).
|
|
449
|
+
prior = _read_install_json(canonical)
|
|
450
|
+
if prior is not None and prior.get("wallet") and prior["wallet"] != wallet:
|
|
451
|
+
return _refused(target, wallet, TeachingError(
|
|
452
|
+
"CMP204", f"this content is already installed on the box bound to wallet "
|
|
453
|
+
f"{prior['wallet']}.",
|
|
454
|
+
"A content-addressed hash dir is immutable and holds ONE wallet binding. To run the "
|
|
455
|
+
"same strategy on a different wallet, re-author it as a distinct strategy (the "
|
|
456
|
+
"multi-wallet/update flow is out of phase-1 scope).",
|
|
457
|
+
Location(),
|
|
458
|
+
), hash8=hash8, runtime_id=rid, canonical_dir=str(canonical))
|
|
459
|
+
# Idempotent no-op: content already landed for this wallet.
|
|
460
|
+
if installed_marker:
|
|
461
|
+
return InstallResult(
|
|
462
|
+
target=target, verdict=ALREADY_INSTALLED, wallet=wallet, hash8=hash8,
|
|
463
|
+
runtime_id=rid, canonical_dir=str(canonical),
|
|
464
|
+
message=f"runtime {rid!r} already installed for this content + wallet (no-op).",
|
|
465
|
+
)
|
|
466
|
+
# Dir landed + bound but never registered (interrupted run) — resume at (d), skip re-copy.
|
|
467
|
+
else:
|
|
468
|
+
err = _copy_verified(staged, canonical, artifacts)
|
|
469
|
+
if err is not None:
|
|
470
|
+
return _refused(target, wallet, err, hash8=hash8, runtime_id=rid)
|
|
471
|
+
|
|
472
|
+
# (c) WALLET BIND — into the COPIED runtime.yaml only; the staged source stays pristine.
|
|
473
|
+
copied_runtime = canonical / "main" / "runtime.yaml"
|
|
474
|
+
bound_text, n_bound = _WALLET_TOKEN.subn(wallet, copied_runtime.read_text(encoding="utf-8"))
|
|
475
|
+
copied_runtime.write_text(bound_text, encoding="utf-8")
|
|
476
|
+
|
|
477
|
+
# Re-run the placeholder policy on the BOUND yaml: any remaining bare ${VAR} is now an error.
|
|
478
|
+
leftover = bare_env_placeholders(bound_text)
|
|
479
|
+
if leftover:
|
|
480
|
+
return _refused(target, wallet, TeachingError(
|
|
481
|
+
"CMP115", f"unbound env placeholder(s) remain after wallet binding: {', '.join(leftover)}.",
|
|
482
|
+
"Only the `${<STRATEGY>_WALLET}` placeholder is bound at install; every other `${VAR}` "
|
|
483
|
+
"resolves to \"\" at load and breaks the config. Remove it or give it a default "
|
|
484
|
+
"(`${VAR:-default}`) in the graph, then re-`composer deploy`.",
|
|
485
|
+
Location(path=""),
|
|
486
|
+
), hash8=hash8, runtime_id=rid, canonical_dir=str(canonical))
|
|
487
|
+
|
|
488
|
+
_write_install_json(canonical, wallet=wallet, hash8=hash8, strategy=strategy,
|
|
489
|
+
runtime_id=rid, bound_count=n_bound, bound_at=bound_at)
|
|
490
|
+
|
|
491
|
+
# (d) INVOKE the frozen consumer through the seam (PATH mode).
|
|
492
|
+
create = create_fn(str(copied_runtime), rid)
|
|
493
|
+
|
|
494
|
+
# (e) VERIFY + REPORT.
|
|
495
|
+
if create.ok:
|
|
496
|
+
auto = " (wallet auto-bound from wallet.json)" if auto_bound else ""
|
|
497
|
+
return InstallResult(
|
|
498
|
+
target=target, verdict=INSTALLED, wallet=wallet, hash8=hash8, runtime_id=rid,
|
|
499
|
+
canonical_dir=str(canonical),
|
|
500
|
+
message=f"installed runtime {rid!r} on wallet {wallet}{auto} from {canonical}.",
|
|
501
|
+
)
|
|
502
|
+
err = _map_create_failure(create)
|
|
503
|
+
return InstallResult(
|
|
504
|
+
target=target, verdict=FAILED, wallet=wallet, hash8=hash8, runtime_id=rid,
|
|
505
|
+
canonical_dir=str(canonical), errors=[err], message=err.message,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# --------------------------------------------------------------------------- fs helpers
|
|
510
|
+
|
|
511
|
+
def _verify_tree(root: Path, artifacts: dict) -> list[str]:
|
|
512
|
+
"""Return the sorted relpaths whose on-disk bytes do NOT match the manifest hash (missing
|
|
513
|
+
files count as mismatched)."""
|
|
514
|
+
bad: list[str] = []
|
|
515
|
+
for rel, sha in artifacts.items():
|
|
516
|
+
f = root / rel
|
|
517
|
+
if not f.is_file() or _sha256_file(f) != sha:
|
|
518
|
+
bad.append(rel)
|
|
519
|
+
return sorted(bad)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _copy_verified(staged: Path, canonical: Path, artifacts: dict) -> Optional[TeachingError]:
|
|
523
|
+
"""Copy the staged tree to a temp sibling, verify EVERY manifest artifact against its hash,
|
|
524
|
+
then atomic-rename into ``canonical``. Returns a CMP203 teaching error on a hash mismatch
|
|
525
|
+
(the temp dir is cleaned up and nothing lands). Never edits in place."""
|
|
526
|
+
canonical.parent.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
tmp = canonical.parent / f".{canonical.name}.tmp-{uuid.uuid4().hex[:8]}"
|
|
528
|
+
if tmp.exists():
|
|
529
|
+
shutil.rmtree(tmp)
|
|
530
|
+
shutil.copytree(staged, tmp)
|
|
531
|
+
mismatched = _verify_tree(tmp, artifacts)
|
|
532
|
+
if mismatched:
|
|
533
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
534
|
+
return TeachingError(
|
|
535
|
+
"CMP203", f"copied file(s) do not match manifest.json: {', '.join(mismatched)}.",
|
|
536
|
+
"The copy into the canonical hash dir was corrupted (tamper or IO fault). Nothing was "
|
|
537
|
+
"installed. Retry `composer install`; if it persists, re-`composer deploy` the graph.",
|
|
538
|
+
Location(),
|
|
539
|
+
)
|
|
540
|
+
os.rename(tmp, canonical)
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _read_install_json(canonical: Path) -> Optional[dict]:
|
|
545
|
+
p = canonical / INSTALL_JSON
|
|
546
|
+
try:
|
|
547
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
548
|
+
except (OSError, json.JSONDecodeError):
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _write_install_json(canonical: Path, *, wallet: str, hash8: str, strategy: str,
|
|
553
|
+
runtime_id: str, bound_count: int, bound_at: Optional[str]) -> None:
|
|
554
|
+
"""Record the wallet binding beside the copied tree (the resumable-by-artifact marker for
|
|
555
|
+
step c). ``bound_at`` is an INJECTED value (no wall-clock) — None when the caller omits it."""
|
|
556
|
+
payload = {
|
|
557
|
+
"strategy": strategy,
|
|
558
|
+
"wallet": wallet,
|
|
559
|
+
"hash8": hash8,
|
|
560
|
+
"runtime_id": runtime_id,
|
|
561
|
+
"runtime_yaml": "main/runtime.yaml",
|
|
562
|
+
"wallet_bindings_applied": bound_count,
|
|
563
|
+
"bound_at": bound_at,
|
|
564
|
+
}
|
|
565
|
+
(canonical / INSTALL_JSON).write_text(
|
|
566
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _registry_has(base_state_dir: Path, runtime_id: str) -> bool:
|
|
571
|
+
"""True if the box registry already lists ``runtime_id`` (install-complete marker written by
|
|
572
|
+
the frozen installRuntime, strategy-registry.ts). Read-only — the composer never writes it."""
|
|
573
|
+
reg = base_state_dir / REGISTRY_FILENAME
|
|
574
|
+
try:
|
|
575
|
+
data = json.loads(reg.read_text(encoding="utf-8"))
|
|
576
|
+
except (OSError, json.JSONDecodeError):
|
|
577
|
+
return False
|
|
578
|
+
return any(
|
|
579
|
+
isinstance(e, dict) and e.get("id") == runtime_id
|
|
580
|
+
for e in (data.get("runtimes") or [])
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def install_target(target, wallet, base_state_dir, **kw) -> InstallResult:
|
|
585
|
+
"""Path-taking convenience wrapper mirroring ``deploy_file`` / ``check_file``."""
|
|
586
|
+
return run_install(str(target), wallet, str(base_state_dir), **kw)
|