@senpi/runtime 3.0.59 → 3.0.63

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 (151) hide show
  1. package/README.md +73 -2
  2. package/dist/actions/decision-engine.d.ts +0 -2
  3. package/dist/actions/decision-engine.d.ts.map +1 -1
  4. package/dist/actions/decision-engine.js +1 -79
  5. package/dist/actions/decision-engine.js.map +1 -1
  6. package/dist/cli/deploy-render.d.ts +91 -0
  7. package/dist/cli/deploy-render.d.ts.map +1 -0
  8. package/dist/cli/deploy-render.js +216 -0
  9. package/dist/cli/deploy-render.js.map +1 -0
  10. package/dist/cli/guide-commands.d.ts.map +1 -1
  11. package/dist/cli/guide-commands.js +78 -6
  12. package/dist/cli/guide-commands.js.map +1 -1
  13. package/dist/cli/senpi-commands.d.ts +6 -1
  14. package/dist/cli/senpi-commands.d.ts.map +1 -1
  15. package/dist/cli/senpi-commands.js +244 -8
  16. package/dist/cli/senpi-commands.js.map +1 -1
  17. package/dist/cli/validate-command.d.ts.map +1 -1
  18. package/dist/cli/validate-command.js +32 -1
  19. package/dist/cli/validate-command.js.map +1 -1
  20. package/dist/deploy/deadlines.d.ts +47 -0
  21. package/dist/deploy/deadlines.d.ts.map +1 -0
  22. package/dist/deploy/deadlines.js +56 -0
  23. package/dist/deploy/deadlines.js.map +1 -0
  24. package/dist/deploy/error-text.d.ts +25 -0
  25. package/dist/deploy/error-text.d.ts.map +1 -0
  26. package/dist/deploy/error-text.js +42 -0
  27. package/dist/deploy/error-text.js.map +1 -0
  28. package/dist/deploy/funding.d.ts +87 -0
  29. package/dist/deploy/funding.d.ts.map +1 -0
  30. package/dist/deploy/funding.js +229 -0
  31. package/dist/deploy/funding.js.map +1 -0
  32. package/dist/deploy/install-gate-runner.d.ts +12 -0
  33. package/dist/deploy/install-gate-runner.d.ts.map +1 -0
  34. package/dist/deploy/install-gate-runner.js +17 -0
  35. package/dist/deploy/install-gate-runner.js.map +1 -0
  36. package/dist/deploy/job.d.ts +199 -0
  37. package/dist/deploy/job.d.ts.map +1 -0
  38. package/dist/deploy/job.js +463 -0
  39. package/dist/deploy/job.js.map +1 -0
  40. package/dist/deploy/min-budget.d.ts +68 -0
  41. package/dist/deploy/min-budget.d.ts.map +1 -0
  42. package/dist/deploy/min-budget.js +297 -0
  43. package/dist/deploy/min-budget.js.map +1 -0
  44. package/dist/deploy/orchestrator.d.ts +305 -0
  45. package/dist/deploy/orchestrator.d.ts.map +1 -0
  46. package/dist/deploy/orchestrator.js +2910 -0
  47. package/dist/deploy/orchestrator.js.map +1 -0
  48. package/dist/deploy/package.d.ts +130 -0
  49. package/dist/deploy/package.d.ts.map +1 -0
  50. package/dist/deploy/package.js +351 -0
  51. package/dist/deploy/package.js.map +1 -0
  52. package/dist/deploy/proof-gate.d.ts +82 -0
  53. package/dist/deploy/proof-gate.d.ts.map +1 -0
  54. package/dist/deploy/proof-gate.js +269 -0
  55. package/dist/deploy/proof-gate.js.map +1 -0
  56. package/dist/deploy/python-float.d.ts +34 -0
  57. package/dist/deploy/python-float.d.ts.map +1 -0
  58. package/dist/deploy/python-float.js +46 -0
  59. package/dist/deploy/python-float.js.map +1 -0
  60. package/dist/deploy/recheck-commands.d.ts +50 -0
  61. package/dist/deploy/recheck-commands.d.ts.map +1 -0
  62. package/dist/deploy/recheck-commands.js +59 -0
  63. package/dist/deploy/recheck-commands.js.map +1 -0
  64. package/dist/deploy/register.d.ts +99 -0
  65. package/dist/deploy/register.d.ts.map +1 -0
  66. package/dist/deploy/register.js +689 -0
  67. package/dist/deploy/register.js.map +1 -0
  68. package/dist/deploy/taxonomy.d.ts +24 -0
  69. package/dist/deploy/taxonomy.d.ts.map +1 -0
  70. package/dist/deploy/taxonomy.js +31 -0
  71. package/dist/deploy/taxonomy.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +244 -132
  74. package/dist/index.js.map +1 -1
  75. package/dist/runtime/strategy-registry.d.ts +8 -0
  76. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  77. package/dist/runtime/strategy-registry.js +14 -1
  78. package/dist/runtime/strategy-registry.js.map +1 -1
  79. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  80. package/dist/runtime-api/intake/intake-mount.js +7 -4
  81. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  82. package/dist/scanners/implementations/market-regime.d.ts +1 -1
  83. package/dist/senpi/client.d.ts +58 -4
  84. package/dist/senpi/client.d.ts.map +1 -1
  85. package/dist/senpi/client.js +192 -15
  86. package/dist/senpi/client.js.map +1 -1
  87. package/dist/senpi/strategy-status.d.ts +32 -0
  88. package/dist/senpi/strategy-status.d.ts.map +1 -0
  89. package/dist/senpi/strategy-status.js +70 -0
  90. package/dist/senpi/strategy-status.js.map +1 -0
  91. package/dist/senpi/types.d.ts +47 -3
  92. package/dist/senpi/types.d.ts.map +1 -1
  93. package/dist/skills-manager/manifest.js +1 -1
  94. package/dist/skills-manager/skills-manifest.json +1 -1
  95. package/dist/telemetry/metrics.d.ts +23 -0
  96. package/dist/telemetry/metrics.d.ts.map +1 -1
  97. package/dist/telemetry/metrics.js +65 -0
  98. package/dist/telemetry/metrics.js.map +1 -1
  99. package/dist/types/strategy.d.ts +9 -1
  100. package/dist/types/strategy.d.ts.map +1 -1
  101. package/dist/universe/package-universe.d.ts +97 -0
  102. package/dist/universe/package-universe.d.ts.map +1 -0
  103. package/dist/universe/package-universe.js +275 -0
  104. package/dist/universe/package-universe.js.map +1 -0
  105. package/dist/validate/codes.d.ts +20 -0
  106. package/dist/validate/codes.d.ts.map +1 -1
  107. package/dist/validate/codes.js +36 -0
  108. package/dist/validate/codes.js.map +1 -1
  109. package/dist/validate/import-stage.js +5 -5
  110. package/dist/validate/import-stage.js.map +1 -1
  111. package/dist/validate/index.d.ts +1 -1
  112. package/dist/validate/index.d.ts.map +1 -1
  113. package/dist/validate/index.js.map +1 -1
  114. package/dist/validate/install-gate.d.ts.map +1 -1
  115. package/dist/validate/install-gate.js +10 -2
  116. package/dist/validate/install-gate.js.map +1 -1
  117. package/dist/validate/live-instruments.d.ts +44 -0
  118. package/dist/validate/live-instruments.d.ts.map +1 -0
  119. package/dist/validate/live-instruments.js +71 -0
  120. package/dist/validate/live-instruments.js.map +1 -0
  121. package/dist/validate/live-stage.js +5 -5
  122. package/dist/validate/live-stage.js.map +1 -1
  123. package/dist/validate/proof.d.ts +11 -1
  124. package/dist/validate/proof.d.ts.map +1 -1
  125. package/dist/validate/proof.js +7 -6
  126. package/dist/validate/proof.js.map +1 -1
  127. package/dist/validate/recipe-checks.d.ts.map +1 -1
  128. package/dist/validate/recipe-checks.js +6 -4
  129. package/dist/validate/recipe-checks.js.map +1 -1
  130. package/dist/validate/run.d.ts +5 -0
  131. package/dist/validate/run.d.ts.map +1 -1
  132. package/dist/validate/run.js +10 -1
  133. package/dist/validate/run.js.map +1 -1
  134. package/dist/validate/target.d.ts +41 -0
  135. package/dist/validate/target.d.ts.map +1 -1
  136. package/dist/validate/target.js +192 -16
  137. package/dist/validate/target.js.map +1 -1
  138. package/dist/validate/universe-stage.d.ts +71 -0
  139. package/dist/validate/universe-stage.d.ts.map +1 -0
  140. package/dist/validate/universe-stage.js +183 -0
  141. package/dist/validate/universe-stage.js.map +1 -0
  142. package/openclaw.plugin.json +1 -1
  143. package/package.json +1 -1
  144. package/dist/runtime/managed-wallet-guard.d.ts +0 -47
  145. package/dist/runtime/managed-wallet-guard.d.ts.map +0 -1
  146. package/dist/runtime/managed-wallet-guard.js +0 -106
  147. package/dist/runtime/managed-wallet-guard.js.map +0 -1
  148. package/dist/runtime/protection-nudge.d.ts +0 -67
  149. package/dist/runtime/protection-nudge.d.ts.map +0 -1
  150. package/dist/runtime/protection-nudge.js +0 -244
  151. package/dist/runtime/protection-nudge.js.map +0 -1
@@ -0,0 +1,689 @@
1
+ import { describeScannerEnabledOffense, instancesWithoutDslExit, loadDeployPackage, lowercasePackageIdFix, nonLowercasePackageId, readRuntimeYaml, runtimeUsesLlmDecisions, sanitizeStrategyName, scannersDeclaringEnabled, walletName, } from "./package.js";
2
+ import { budgetArg, MIN_WALLET, usd } from "./funding.js";
3
+ import { deployPyValidateCommand, PROOF_ROUTE_NOTE, recheckBlock, } from "./recheck-commands.js";
4
+ import path from "node:path";
5
+ import { withSpan } from "../telemetry/tracing.js";
6
+ import { recordDeployRollback, recordDeployStep } from "../telemetry/metrics.js";
7
+ import { isClosing, isDead } from "../senpi/strategy-status.js";
8
+ import { DEFAULT_MAX_WAIT_SEC, DEFAULT_TICK_WAIT_SEC, isWaitInRange, jobCapMs, MAX_WAIT_SEC, MIN_WAIT_SEC, } from "./deadlines.js";
9
+ import { extractTaxonomyCode } from "./taxonomy.js";
10
+ import { runDeploy } from "./orchestrator.js";
11
+ import { MCP_CALL_TIMEOUT_MS } from "./job.js";
12
+ /** The real clock for every orchestrator poll loop (create-ACTIVE and observe-tick alike). */
13
+ const POLL_SLEEP = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ // ─── refusal / message builders ─────────────────────────────────────────────
15
+ function invalidRequest(message) {
16
+ return { code: "INVALID_REQUEST", message };
17
+ }
18
+ function notFound(message) {
19
+ return { code: "NOT_FOUND", message };
20
+ }
21
+ /**
22
+ * The package, or one of its instance runtime.yamls, could not be read or parsed.
23
+ *
24
+ * Carries the code every other start-time refusal carries, and not for tidiness: the CLI classifies
25
+ * a start error by its code (`[…]` → exit 2 = refused, otherwise 1 = internal/transport). This was
26
+ * the one refusal that relayed a bare parser message, so it exited 1 — "retry me" — and retrying a
27
+ * malformed package never helps.
28
+ *
29
+ * TWO states reach it, and they differ in exactly one thing that matters — whether the instances are
30
+ * known. `loadDeployPackage` failing means there is no package at all, and the root is then the only
31
+ * target either re-check can take; it is also the right one, since `senpi validate` at a package root
32
+ * whose manifest will not read refuses with that same manifest error rather than validating
33
+ * something else. A pre-check failing means the manifest READ fine and one of its recipes will not
34
+ * parse — the package is fully known there, so the re-check names every instance directory. Naming
35
+ * the root on that path would send a multi-instance reader to `[E_VALIDATE_NO_RECIPE] … pick an
36
+ * instance`: a second refusal in place of the check they were told to run.
37
+ */
38
+ function buildUnreadablePackageRefusal(packageDir, err, pkg) {
39
+ const what = pkg
40
+ ? `Could not read a runtime recipe in the strategy package at ${packageDir}`
41
+ : `Could not read the strategy package at ${packageDir}`;
42
+ const dirs = pkg ? pkg.instances.map((inst) => inst.runtimeYamlDir) : [packageDir];
43
+ return (`[INVALID_REQUEST] ${what}: ` +
44
+ `${err instanceof Error ? err.message : String(err)}. Nothing was started. Fix the package, ` +
45
+ `then re-check it read-only with either route:\n` +
46
+ // The python route always takes the package: it validates one, and there is no instance form
47
+ // of it to render.
48
+ `${recheckBlock(dirs, deployPyValidateCommand(packageDir))}\n` +
49
+ `${PROOF_ROUTE_NOTE} Then re-run.`);
50
+ }
51
+ function buildMissingPackageDirRefusal() {
52
+ return ("[INVALID_REQUEST] A strategy package directory is required. Nothing was started. " +
53
+ "Run: openclaw senpi deploy -p <package-dir> --budget <usd>");
54
+ }
55
+ /** Hint computed from the LIVE job, never templated — the taxonomy rule for every refusal. */
56
+ function buildInProgressRefusal(running) {
57
+ return (`[E_DEPLOY_IN_PROGRESS] deploy ${running.deployId} is ${running.phase} for package ` +
58
+ `${running.packageId}. One deploy runs at a time. Watch it: openclaw senpi deploy status. A wedged job ` +
59
+ `times out and frees the slot on its own.`);
60
+ }
61
+ function buildDecisionModelRefusal(instances, packageDir) {
62
+ return (`[INVALID_REQUEST] Instance(s) ${instances.join(", ")} run actions with decision_mode: llm, but ` +
63
+ `no decision model was given. Nothing was started. Re-run naming the model: ` +
64
+ `openclaw senpi deploy -p '${packageDir}' --budget <usd> --decision-model <model>`);
65
+ }
66
+ function buildDecisionModelEnvRefusal(instances, packageDir) {
67
+ return (`[INVALID_REQUEST] Instance(s) ${instances.join(", ")} run actions with decision_mode: llm, but ` +
68
+ `${packageDir}/strategy.yaml declares no defaults.decision_model_env — there is no token to ` +
69
+ `substitute the model into. Nothing was started. Add defaults.decision_model_env to ` +
70
+ `strategy.yaml, bind it in the runtime.yaml, then re-run.`);
71
+ }
72
+ function buildNoDslExitRefusal(instances, packageDir) {
73
+ return (`[INVALID_REQUEST] Instance(s) ${instances.map((inst) => inst.name).join(", ")} declare no DSL ` +
74
+ `exit block (\`exit.dsl_preset\`, or \`exit.engine: dsl\`) — every position they open would run ` +
75
+ `with no stop loss and no trailing floor. Nothing was started. Add an exit block to the instance ` +
76
+ `runtime.yaml, then re-check read-only with either route:\n` +
77
+ // One `senpi validate` per OFFENDING instance directory, computed from the instance rather than
78
+ // templated: the offence is per-recipe, and so is the proof that clears it.
79
+ `${recheckBlock(instances.map((inst) => inst.runtimeYamlDir), deployPyValidateCommand(packageDir))}\n` +
80
+ `${PROOF_ROUTE_NOTE} Then re-run.`);
81
+ }
82
+ /**
83
+ * D-2 (revised 2026-08-05): a scanner-level `enabled` key is refused outright — the engine never
84
+ * reads it, so accepting `enabled: false` would ship a strategy that ticks and trades while its
85
+ * author believes it is switched off. Package-derivable, so it refuses here with the other local
86
+ * pre-checks, before a job exists let alone a wallet; the orchestrator keeps its own copy.
87
+ *
88
+ * Every offense is enumerated in ONE refusal, each as a delete-this-line instruction naming the
89
+ * key, the scanner, the instance and the file: a reader (agent or human) fixes the whole package in
90
+ * one pass instead of re-running to discover the next one.
91
+ */
92
+ function buildScannerEnabledUnsupportedRefusal(offenders, packageDir) {
93
+ return (`[INVALID_REQUEST] Unsupported \`enabled\` key on ${offenders.length} scanner entr` +
94
+ `${offenders.length === 1 ? "y" : "ies"}. Nothing was started. Fix — delete these lines:\n` +
95
+ offenders.map((entry) => ` - ${describeScannerEnabledOffense(entry)}`).join("\n") +
96
+ `\nWhy: the runtime engine never reads a scanner-level \`enabled\` (the scanner schema passes ` +
97
+ `it through untyped and registration comes from the strategy, not the scanner entry), so an ` +
98
+ `\`enabled: false\` scanner registers ENABLED and ticks — the package would trade while you ` +
99
+ `believe it is off. A scanner runs because the package declares it: to stop one, remove the ` +
100
+ `scanner entry itself, or disable it after install through the runtime API (\`disableScanner\`). ` +
101
+ `Then re-run: openclaw senpi deploy -p '${packageDir}' --budget <usd>`);
102
+ }
103
+ /**
104
+ * A package id carrying capitals, refused before a job exists.
105
+ *
106
+ * The id becomes each wallet's `skillName` attribution stamp VERBATIM at create, and the backend
107
+ * stores what it is given case-normalized — so a mixed-case id is stamped under one spelling and
108
+ * looked up under another. Package-derivable, so it refuses here with the other local pre-checks;
109
+ * the orchestrator keeps its own copy, and both build their fix from the one producer so the edit
110
+ * an author reads is word-for-word the same wherever the refusal is raised.
111
+ *
112
+ * The re-run command names the directory the fix LEAVES BEHIND, not the one that failed: this is
113
+ * the only refusal in the verb whose own instruction can move the package.
114
+ */
115
+ function buildNonLowercaseIdRefusal(pkg) {
116
+ const fix = lowercasePackageIdFix(pkg.id, pkg.dir);
117
+ return (`[INVALID_REQUEST] strategy.yaml id "${pkg.id}" must be lowercase. Nothing was started. ` +
118
+ `Fix — ${fix.instruction}. Why: deploy writes the id into each wallet's \`skillName\` ` +
119
+ `attribution stamp VERBATIM at create, and the backend stores what it is given ` +
120
+ `case-normalized, so a mixed-case id is stamped under one spelling and looked up under ` +
121
+ `another — that is how \`close.py '${pkg.id}'\` came to report "no OPEN strategies to close." ` +
122
+ `over a live, funded, trading wallet. Every reader now folds case, so wallets already stamped ` +
123
+ `in any case are still found and this package will ADOPT the ones it already owns rather than ` +
124
+ `fund a second set beside them. Then re-run: ` +
125
+ `openclaw senpi deploy -p '${fix.dirAfterFix}' --budget <usd>`);
126
+ }
127
+ /**
128
+ * `--budget 0` parses (`readNumber(0)` → 0) and the required-budget gate checks only `undefined`,
129
+ * so without this gate a zero budget reaches `planFunding`, which floors every wallet to
130
+ * MIN_WALLET — funding money the user never asked to spend. `--budget ≤ $0` is the taxonomy's
131
+ * rule-4 field case: its only known source is a buggy computed hint, so the refusal names the real
132
+ * fix. The orchestrator's preflight keeps its own copy of this gate, so every caller stays gated.
133
+ */
134
+ function buildNonPositiveBudgetRefusal(budget, packageDir) {
135
+ return (`[INVALID_REQUEST] --budget must be a positive USD amount (got ${budgetArg(budget)}): a zero or ` +
136
+ `negative budget cannot fund any wallet at the ${usd(MIN_WALLET)}/wallet floor. Nothing was ` +
137
+ `started. If a report suggested \`--budget ${budgetArg(budget)}\`, that hint was a bug, not an ` +
138
+ `instruction — a sleeve declaring \`funding_share: 0\` is fixed by giving it a share in ` +
139
+ `strategy.yaml, never by re-deploying. Re-run: openclaw senpi deploy -p '${packageDir}' --budget <usd>`);
140
+ }
141
+ /**
142
+ * A wait the verb will not run on. Money-safe either way — nothing is created before this gate —
143
+ * so what it prevents is narration: an out-of-range wait produces a job cap that the timer rewrites
144
+ * (Node clamps a delay past 2^31-1 to 1ms) or that is already in the past, and the journal then
145
+ * reports "deploy deadline (999999999s) exceeded" about a run that lasted milliseconds.
146
+ *
147
+ * Names the value it got, the range, and the default the flag falls back to when omitted — the
148
+ * three things a caller needs to fix a typo without guessing.
149
+ */
150
+ function buildWaitOutOfRangeRefusal(bad, packageDir) {
151
+ return (`[INVALID_REQUEST] ${bad.flag} must be between ${MIN_WAIT_SEC} and ${MAX_WAIT_SEC} seconds ` +
152
+ `(got ${bad.value}). Nothing was started. Outside that range the job's wall-clock cap is not a ` +
153
+ `deadline the run can honour — a negative one is already past, and a huge one overflows the ` +
154
+ `timer and fires at once — so the deploy would be abandoned before it began while the journal ` +
155
+ `narrated the deadline you asked for. Re-run with a value in range, or omit the flag for the ` +
156
+ `default (${bad.fallback}s): openclaw senpi deploy -p '${packageDir}' --budget <usd>`);
157
+ }
158
+ function buildNoJobsRefusal() {
159
+ return ("[NOT_FOUND] No deploy has been started on this agent. Start one: " +
160
+ "openclaw senpi deploy -p <package-dir> --budget <usd>");
161
+ }
162
+ /**
163
+ * D-6, crash case: the boot scan never moves money, so an interrupted deploy may have left a funded
164
+ * wallet with no runtime on it. The resume command alone would hide that — name BOTH exits, and the
165
+ * amount, computed from the fresh live read rather than from the journal.
166
+ *
167
+ * A wallet whose `totalFunded` the backend did not report is IN this warning with its figure named
168
+ * unreadable: dropping it (what `?? 0` did) is the not-funded direction of the same rule that
169
+ * forbids printing it as `$0.00`, and it silenced the whole warning whenever that was the only
170
+ * wallet. No sum is quoted over a set that contains one.
171
+ *
172
+ * SCOPED by the same one-authority/precondition rule as the budget escape (this was the last
173
+ * un-scoped teardown hint in the verb): the live read includes the runtime registry, so "nothing is
174
+ * watching it" is only ever said about a wallet with NO registered runtime, and every teardown this
175
+ * function offers targets the addresses the read actually returned. A runtimeless wallet's route is
176
+ * a direct `strategy_close` on the address — `close.py --instance` resolves through the runtime it
177
+ * does not have (mirrors E_ROLLBACK_INCOMPLETE's instruction). The package-wide `close.py <id>` is
178
+ * never the offered route, on any branch: it discovers targets by attribution stamp and can reach
179
+ * strategies this read cannot enumerate, so it is named only with that radius stated. A wallet WITH
180
+ * a registered runtime gets no teardown offer at all: closing it would tear down a strategy that
181
+ * may be running as intended.
182
+ */
183
+ function buildInterruptedNext(meta, live) {
184
+ const resume = buildResumeCommand(meta);
185
+ // An UNREADABLE `totalFunded` stays in: `?? 0` read it as not-funded and dropped the wallet from
186
+ // this warning altogether, so a lone stranded wallet whose figure the backend never reported got
187
+ // the bare resume command with no mention of money at all. Under-warning is the same failure as
188
+ // rendering the figure as $0.00 — unknown is money of unknown size until someone reads it.
189
+ const funded = live.strategies.filter((s) => (s.totalFunded === undefined || s.totalFunded > 0) && !isDead(s.status) && !isClosing(s.status));
190
+ if (funded.length === 0)
191
+ return resume;
192
+ const runtimeWallets = new Set(live.runtimes.map((rt) => rt.wallet?.toLowerCase()).filter((w) => Boolean(w)));
193
+ /**
194
+ * "Is a LIVE runtime on this wallet?" — `live.runtimes` now carries only ids with a live handle
195
+ * (see `readDeployLiveLayer`), so this answers that and not the weaker "was one installed here
196
+ * once". The wording of both branches below is written to that meaning.
197
+ */
198
+ const hasRuntime = (s) => Boolean(s.strategyWalletAddress) && runtimeWallets.has(s.strategyWalletAddress.toLowerCase());
199
+ const unwatched = funded.filter((s) => !hasRuntime(s));
200
+ const watched = funded.filter(hasRuntime);
201
+ const total = (rows) => rows.reduce((sum, s) => sum + (s.totalFunded ?? 0), 0);
202
+ /** Bare addresses — the form that goes into a command, where a figure would read as an argument. */
203
+ const addresses = (rows) => rows.map((s) => s.strategyWalletAddress || "(address unreadable)").join(", ");
204
+ /** Each wallet carries its OWN figure — a set that mixes read and unread amounts has no total. */
205
+ const names = (rows) => rows
206
+ .map((s) => `${s.strategyWalletAddress || "(address unreadable)"} ` +
207
+ `(${s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded)})`)
208
+ .join(", ");
209
+ /**
210
+ * The aggregate that follows the list. A sum is only honest when every figure in it was read, so
211
+ * an unread one turns the sum into a floor (or removes it) and carries the caution instead.
212
+ */
213
+ const money = (rows) => {
214
+ const unread = rows.filter((s) => s.totalFunded === undefined).length;
215
+ const parts = [];
216
+ if (rows.length > 1) {
217
+ parts.push(unread === rows.length
218
+ ? "no readable funded amount on any of them"
219
+ : `${usd(total(rows))} in total${unread > 0 ? " across the readable ones" : ""}`);
220
+ }
221
+ if (unread > 0) {
222
+ parts.push(`an unreadable amount is UNKNOWN, never zero — treat ` +
223
+ `${unread === 1 ? "that wallet" : "those wallets"} as funded until the balance is verified`);
224
+ }
225
+ return parts.join("; ");
226
+ };
227
+ const aggregate = (rows) => {
228
+ const clause = money(rows);
229
+ return clause === "" ? "" : ` (${clause})`;
230
+ };
231
+ if (unwatched.length === 0) {
232
+ return (`${resume}. ${watched.length} live wallet(s) already carry a LIVE runtime: ` +
233
+ `${names(watched)}${aggregate(watched)} — no teardown is needed to recover; re-run to ` +
234
+ `adopt and verify. If you meant to undo this deploy instead, ` +
235
+ `\`python3 senpi-strategy-ops/scripts/close.py '${meta.packageId}'\` closes every live sleeve ` +
236
+ `of ${meta.packageId} and returns the funds.`);
237
+ }
238
+ const one = unwatched.length === 1;
239
+ const subject = one
240
+ ? `Wallet ${names(unwatched)} has`
241
+ : `${unwatched.length} wallets (${names(unwatched)}) have`;
242
+ const lead = `${resume}. ${subject} NO live runtime on it${aggregate(unwatched)} — ` +
243
+ `nothing is watching ${one ? "it" : "them"} right now: either re-run to adopt`;
244
+ /**
245
+ * The reclaim route is the SAME on both branches, and it is the narrow one: a `strategy_close` on
246
+ * the addresses this read actually returned.
247
+ *
248
+ * The wide `close.py '<id>'` cannot be it, on either branch. It discovers its targets from
249
+ * `strategy_list` by attribution stamp — every live strategy stamped with this package id —
250
+ * whereas `live.strategies` is deliberately scoped to the names the manifest produces TODAY plus
251
+ * the create keys THIS deployId journaled. The two diverge whenever an earlier sleeve is live
252
+ * under a name the current manifest does not produce: an instance renamed, or the reachable one —
253
+ * a prior create whose custom name the backend rejected, which lands under a backend-assigned
254
+ * name and which this very verb has a retry for because it was observed in production (SERR083).
255
+ * So "nothing else of <id> is live, so that closes exactly the wallet(s) named above" was a claim
256
+ * about a set this read never enumerated, printed one branch away from the sibling branch's
257
+ * explicit "Do NOT use close.py". The narrow route names exactly what was read.
258
+ */
259
+ const reclaim = `, or reclaim ${one ? "it" : "them"} with a direct MCP \`strategy_close\` on ` +
260
+ `${addresses(unwatched)} (a wallet with no runtime cannot be reached by \`close.py --instance\`)`;
261
+ if (watched.length === 0) {
262
+ return (`${lead}${reclaim}. \`python3 senpi-strategy-ops/scripts/close.py '${meta.packageId}'\` is a ` +
263
+ `wider move than that: it closes every live strategy stamped ${meta.packageId}, and this read ` +
264
+ `covers only the wallets this package's manifest names plus what this deploy created — an ` +
265
+ `earlier sleeve live under a name the manifest no longer produces is not in the list above ` +
266
+ `but IS in that command's blast radius. Use it only to undo the whole package.`);
267
+ }
268
+ return (`${lead}${reclaim}. ` +
269
+ `Do NOT use \`python3 senpi-strategy-ops/scripts/close.py '${meta.packageId}'\` for this: ` +
270
+ `${watched.length} other wallet(s) (${addresses(watched)}) carry an installed runtime and that ` +
271
+ `command would close them too.`);
272
+ }
273
+ function buildResumeCommand(meta) {
274
+ const budget = meta.budget === undefined ? "" : ` --budget ${budgetArg(meta.budget)}`;
275
+ return (`re-run \`openclaw senpi deploy -p '${meta.packageDir}'${budget}\` to resume — reconciliation adopts what ` +
276
+ `exists; nothing is lost`);
277
+ }
278
+ // ─── deps assembly ──────────────────────────────────────────────────────────
279
+ /**
280
+ * One fresh timeout signal per call. `MCP_CALL_TIMEOUT_MS` lives in `job.ts` beside the watchdog
281
+ * grace it determines — the grace MUST outlast the longest in-flight call, and two hand-synced
282
+ * constants in two files is how that stops being true.
283
+ */
284
+ function callDeadline() {
285
+ return { signal: AbortSignal.timeout(MCP_CALL_TIMEOUT_MS) };
286
+ }
287
+ /**
288
+ * Map the gateway context onto the orchestrator's injected collaborators. The
289
+ * MCP-backed ones share one connected client for the whole job.
290
+ */
291
+ /**
292
+ * The `span` dep: a real OTel span per deploy phase, plus the phase metric.
293
+ *
294
+ * M6: a phase fn never throws (the orchestrator records refusals and failures as outcomes), so
295
+ * `withSpan` never paints one of these spans red for a refusal. Only a genuine escape — a bug —
296
+ * marks a deploy span errored.
297
+ *
298
+ * `deploy.rollback` resolves with its own result, which is the D-6 rollback metric: `incomplete`
299
+ * means a funded wallet is stranded, and that is the alert.
300
+ */
301
+ function buildSpanDep() {
302
+ return async (name, attrs, fn) => withSpan(name, attrs, async (span) => {
303
+ const startedMs = Date.now();
304
+ let status = "error";
305
+ try {
306
+ const result = await fn();
307
+ status = typeof result === "string" ? result : "ok";
308
+ if (name === "deploy.rollback" && (status === "ok" || status === "incomplete")) {
309
+ span.setAttribute("senpi.deploy.rollback.result", status);
310
+ recordDeployRollback({ result: status });
311
+ }
312
+ span.setAttribute("senpi.deploy.step.status", status);
313
+ return result;
314
+ }
315
+ finally {
316
+ recordDeployStep({
317
+ step: String(attrs["senpi.deploy.step"] ?? name),
318
+ status,
319
+ durationSec: (Date.now() - startedMs) / 1000,
320
+ });
321
+ }
322
+ });
323
+ }
324
+ function buildDeployDeps(ctx, client, job) {
325
+ return {
326
+ listStrategies: (filters) => client.listStrategies(filters, callDeadline()),
327
+ createCustomStrategy: (params) => client.createCustomStrategy(params, callDeadline()),
328
+ getPortfolio: () => client.getPortfolio({ forceFetch: true, ...callDeadline() }),
329
+ listLiveInstruments: () => client.listInstrumentNames(callDeadline()),
330
+ installRuntime: (params) => ctx.installRuntime({
331
+ runtimeYamlContent: params.runtimeYamlContent,
332
+ runtimeYamlDir: params.runtimeYamlDir,
333
+ id: params.id,
334
+ }),
335
+ deleteRuntime: (id) => ctx.deleteRuntime(id),
336
+ registryFindByWallet: (wallet) => ctx.registryFindByWallet(wallet),
337
+ registryFindById: (id) => ctx.registryFindById(id),
338
+ getScannerRows: (runtimeId) => ctx.getScannerRows(runtimeId),
339
+ runtimeIsLive: (id) => ctx.runtimeIsLive(id),
340
+ // D-6 rollback surfaces, both deadline-bounded. The flat check especially: it runs INSIDE the
341
+ // rollback, so a hung one costs the `[E_ROLLBACK_INCOMPLETE]` report itself — the job cap would
342
+ // force-terminate with a generic `instances: []` terminal and the stranded funded wallet would
343
+ // never be named. Its clearinghouse ladder bounds attempt count, not wall time; one shared
344
+ // timeout signal across those attempts is the wall-clock bound.
345
+ walletIsFlat: async (wallet) => (await client.listOpenPositions(wallet, callDeadline())).length === 0,
346
+ closeStrategy: (wallet) => client.closeStrategy(wallet, callDeadline()),
347
+ span: buildSpanDep(),
348
+ emit: job.emit,
349
+ shouldAbandon: job.shouldAbandon,
350
+ now: () => Date.now(),
351
+ sleep: POLL_SLEEP,
352
+ log: (line) => ctx.logger.info(line),
353
+ };
354
+ }
355
+ /**
356
+ * Fresh read of the surfaces that actually prove what exists, for an
357
+ * `interrupted` snapshot. Strictly read-only: it never creates, installs or
358
+ * deletes anything, so rendering an interrupted deploy can never mutate one.
359
+ */
360
+ async function readDeployLiveLayer(ctx, meta) {
361
+ let pkg;
362
+ try {
363
+ pkg = loadDeployPackage(meta.packageDir);
364
+ }
365
+ catch (err) {
366
+ return { strategies: [], runtimes: [], unavailable: err instanceof Error ? err.message : String(err) };
367
+ }
368
+ const names = new Set(pkg.instances.map((inst) => walletName(pkg, inst)));
369
+ // C2 again, on the read side: a create whose custom name the backend rejected lands under a
370
+ // backend-assigned name, so a name-only lookup is blind in EXACTLY the crash window this render
371
+ // exists for — a job that created and funded a wallet and then died. Feed the same journaled
372
+ // lookup keys in, and let the backend decide (same carve-out, same one sanctioned reader).
373
+ //
374
+ // Scoped to THIS deploy's journal, unlike reconcile's deliberately package-wide read (D-9): the
375
+ // render attributes what it lists to this job, and an earlier COMPLETED deploy's still-live
376
+ // wallet would be offered up as "re-run to adopt it, or close it to reclaim $X" — an invitation
377
+ // to close a strategy that is running as intended.
378
+ const priorIds = [...ctx.controller.priorCreatedStrategyIds(meta.packageId, meta.deployId).values()].flat();
379
+ let summaries;
380
+ try {
381
+ summaries = await ctx.withSenpiClient(async (client) => {
382
+ const byName = await client.listStrategies(undefined, callDeadline());
383
+ const matched = byName.filter((s) => names.has(s.strategyName));
384
+ if (priorIds.length === 0)
385
+ return matched;
386
+ const byKey = await client.listStrategies({ strategyIds: priorIds }, callDeadline());
387
+ const seen = new Set(matched.map((s) => s.id));
388
+ return [...matched, ...byKey.filter((s) => !seen.has(s.id))];
389
+ });
390
+ }
391
+ catch (err) {
392
+ // `listStrategies` fails closed, so this is a strategy list that could NOT be read — the one
393
+ // thing this layer exists to prove. Rendering it as an empty list would print "strategies: none
394
+ // matching this package" over a job that may have funded wallets, which is the exact claim an
395
+ // interrupted report must never make. Say unavailable instead; the journal narration still
396
+ // prints beside it.
397
+ return {
398
+ strategies: [],
399
+ runtimes: [],
400
+ unavailable: `could not read your strategies: ${err instanceof Error ? err.message : String(err)}`,
401
+ };
402
+ }
403
+ const runtimes = [];
404
+ for (const inst of pkg.instances) {
405
+ const id = sanitizeStrategyName(`${pkg.id}-${inst.name}`);
406
+ const entry = await ctx.registryFindById(id);
407
+ if (!entry)
408
+ continue;
409
+ // H4, and the only question this list is asked: is a wallet's money being WATCHED right now? A
410
+ // registry row does not answer it. The row is written at install time and is never removed by a
411
+ // runtime that then died at boot or never wired its entry scanners, so it is evidence that an
412
+ // install once happened — not that an exit engine is running. Rendered from the row alone,
413
+ // `buildInterruptedNext` told the reader "N live wallet(s) already carry an installed runtime …
414
+ // no teardown is needed to recover" over a funded wallet with no stop loss running: the
415
+ // under-warning direction this whole render forbids elsewhere (an unreadable `totalFunded` is
416
+ // kept IN the warning for the same reason). `runtimeIsLive` is the live-handle check the
417
+ // orchestrator already gates adoption on; it is an in-memory lookup, so this costs nothing and
418
+ // cannot fail on its own.
419
+ //
420
+ // (`readRegistry` swallowing a non-ENOENT read error is a DIFFERENT failure and points the
421
+ // other way: it returns an empty registry, so rows go missing rather than linger. That one is
422
+ // already fail-closed here — no row, no claim that anything is watching.)
423
+ if (!(await ctx.runtimeIsLive(entry.id)))
424
+ continue;
425
+ runtimes.push({ id: entry.id, wallet: entry.wallet });
426
+ }
427
+ return {
428
+ strategies: summaries
429
+ .map((s) => ({
430
+ strategyName: s.strategyName,
431
+ status: s.status,
432
+ strategyWalletAddress: s.strategyWalletAddress,
433
+ totalFunded: s.totalFunded,
434
+ })),
435
+ runtimes,
436
+ };
437
+ }
438
+ // ─── handlers ───────────────────────────────────────────────────────────────
439
+ function readNumber(value) {
440
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
441
+ }
442
+ /**
443
+ * The refusals decidable from the package alone — no MCP, no job. Returns the message, or null when
444
+ * the package is startable.
445
+ *
446
+ * Every check here reads and parses an instance recipe off disk, so this MUST be guarded — a
447
+ * malformed `runtime.yaml` would otherwise escape the gateway handler as a raw throw. Guard it in
448
+ * its OWN try, separate from the one around `loadDeployPackage`: sharing one collapses two states
449
+ * that need different refusals. A throw from the load means there is no package; a throw from here
450
+ * means the manifest read fine and the package's instances are fully known, which is what lets the
451
+ * refusal name each instance directory instead of a package root that `senpi validate` will not
452
+ * take. Merging the guards silently reinstates that defect.
453
+ */
454
+ function localStartRefusal(pkg, decisionModel) {
455
+ const needsModel = pkg.instances
456
+ .filter((inst) => runtimeUsesLlmDecisions(readRuntimeYaml(inst)))
457
+ .map((inst) => inst.name);
458
+ if (needsModel.length > 0) {
459
+ if (!decisionModel)
460
+ return buildDecisionModelRefusal(needsModel, pkg.dir);
461
+ // The other half of the same gate, and the one that used to be missing here: WITH a model but
462
+ // with no token in strategy.yaml to substitute it into, the old code sailed past this check and
463
+ // refused at install — after create+fund had already made and funded a wallet.
464
+ if (!pkg.decisionModelEnv)
465
+ return buildDecisionModelEnvRefusal(needsModel, pkg.dir);
466
+ }
467
+ const unprotected = instancesWithoutDslExit(pkg);
468
+ if (unprotected.length > 0)
469
+ return buildNoDslExitRefusal(unprotected, pkg.dir);
470
+ const enabledKeys = scannersDeclaringEnabled(pkg);
471
+ if (enabledKeys.length > 0)
472
+ return buildScannerEnabledUnsupportedRefusal(enabledKeys, pkg.dir);
473
+ // Last of the local gates: the ones above are money-safety findings (a package that cannot stop
474
+ // itself out, a package that trades while its author believes it is off), and they lead when a
475
+ // package trips several. This one is the canonical-stamp rule.
476
+ if (nonLowercasePackageId(pkg) !== null)
477
+ return buildNonLowercaseIdRefusal(pkg);
478
+ return null;
479
+ }
480
+ /**
481
+ * The synchronous half of a deploy: validate, refuse, claim the slot, return. Wrapped in its own
482
+ * short `deploy.start` span so a refusal (which never creates a job, and so never produces a
483
+ * `deploy.run` span) is still visible in the trace — with its taxonomy code, not an error status.
484
+ */
485
+ async function handleStart(ctx, params, respond) {
486
+ return withSpan("deploy.start", {}, async (span) => {
487
+ const refuse = (error) => {
488
+ const code = extractTaxonomyCode(error.message);
489
+ span.setAttribute("senpi.deploy.outcome", "refused");
490
+ if (code)
491
+ span.setAttribute("senpi.deploy.refusal.code", code);
492
+ respond(false, undefined, error);
493
+ };
494
+ await startBody(ctx, params, respond, refuse, span);
495
+ });
496
+ }
497
+ async function startBody(ctx, params, respond, refuse, span) {
498
+ const requestedDir = typeof params?.packageDir === "string" ? params.packageDir : "";
499
+ if (!requestedDir) {
500
+ refuse(invalidRequest(buildMissingPackageDirRefusal()));
501
+ return;
502
+ }
503
+ // Resolved HERE, at the one place a caller's string enters the verb, and not left to each
504
+ // refusal. `loadDeployPackage` resolves its own copy, so every path derived from a LOADED package
505
+ // (`pkg.dir`, every `runtimeYamlDir`) is already absolute — but the package that would not load
506
+ // has no `pkg`, and its refusal was rendering the caller's string verbatim. A relative path in a
507
+ // command an agent copies into some other working directory is a command that fails, or resolves
508
+ // somewhere nobody meant. Resolution happens after the empty check because `resolve("")` is the
509
+ // process's cwd, which is not what an absent argument means.
510
+ const packageDir = path.resolve(requestedDir);
511
+ const decisionModel = typeof params?.decisionModel === "string" ? params.decisionModel : undefined;
512
+ // Package load AND both local pre-checks are guarded: each of them parses YAML off disk, so a
513
+ // malformed instance runtime.yaml must surface as this teaching refusal rather than escaping the
514
+ // handler as a raw throw. The pre-checks run here (not just in the orchestrator) so the refusal is
515
+ // immediate and no job is ever created; the orchestrator keeps its own copies of both gates, so
516
+ // every caller stays gated.
517
+ //
518
+ // TWO guards, not one, and the difference is what the refusal may say. A throw from the load means
519
+ // there is no package to speak of. A throw from the pre-checks means the manifest read fine and a
520
+ // RECIPE will not parse — the package is known there, so the refusal can name each instance
521
+ // directory instead of a package root a multi-instance `senpi validate` refuses to take.
522
+ let pkg;
523
+ try {
524
+ pkg = loadDeployPackage(packageDir);
525
+ }
526
+ catch (err) {
527
+ refuse(invalidRequest(buildUnreadablePackageRefusal(packageDir, err)));
528
+ return;
529
+ }
530
+ let refusal = null;
531
+ try {
532
+ refusal = localStartRefusal(pkg, decisionModel);
533
+ }
534
+ catch (err) {
535
+ refuse(invalidRequest(buildUnreadablePackageRefusal(packageDir, err, pkg)));
536
+ return;
537
+ }
538
+ if (refusal) {
539
+ refuse(invalidRequest(refusal));
540
+ return;
541
+ }
542
+ const running = ctx.controller.running();
543
+ if (running) {
544
+ refuse({ code: "CONFLICT", message: buildInProgressRefusal(running) });
545
+ return;
546
+ }
547
+ const budget = readNumber(params?.budget);
548
+ if (budget !== undefined && budget <= 0) {
549
+ refuse(invalidRequest(buildNonPositiveBudgetRefusal(budget, pkg.dir)));
550
+ return;
551
+ }
552
+ const maxWaitSec = readNumber(params?.maxWaitSec);
553
+ const tickWaitSec = readNumber(params?.tickWaitSec);
554
+ // Range-checked here for the same reason `budget` is, one gate up: this is where a caller's
555
+ // number enters the verb, and the CLI's own guard only rejects a non-finite one. Both waits feed
556
+ // the job cap, and the cap feeds `setTimeout` — see `deadlines.ts` for what an out-of-range value
557
+ // does to it and to the deadline the journal then narrates.
558
+ const outOfRange = !isWaitInRange(maxWaitSec)
559
+ ? { flag: "--max-wait", value: maxWaitSec, fallback: DEFAULT_MAX_WAIT_SEC }
560
+ : !isWaitInRange(tickWaitSec)
561
+ ? { flag: "--tick-wait", value: tickWaitSec, fallback: DEFAULT_TICK_WAIT_SEC }
562
+ : null;
563
+ if (outOfRange) {
564
+ refuse(invalidRequest(buildWaitOutOfRangeRefusal(outOfRange, pkg.dir)));
565
+ return;
566
+ }
567
+ // C2 carve-out: journaled create keys for this package. They are lookup keys only — reconcile
568
+ // re-reads every one of them from the backend before it may adopt anything.
569
+ const priorStrategyIds = ctx.controller.priorCreatedStrategyIds(pkg.id);
570
+ // D-9, narration tier: strategyId → the amount a prior run journaled as its ask. Feeds the
571
+ // report's funded-vs-requested warn ONLY, so a crash between a partial fund and the report does
572
+ // not resume as a silent adoption. `funded` is still re-read live on this run.
573
+ const priorRequested = ctx.controller.priorRequestedFunding(pkg.id);
574
+ // D-5: the job's wall-clock cap. Sized to what the run can legitimately spend — every instance
575
+ // may burn a full ACTIVE poll plus a full tick-wait — plus 300s of headroom for the install and
576
+ // the un-timed steps. Past it the watchdog stops the run and takes the slot back.
577
+ const capMs = jobCapMs(maxWaitSec, tickWaitSec, pkg.instances.length);
578
+ // L5: the package attribute is the BASENAME, never the absolute path — a user's home directory
579
+ // is not a span dimension, and the basename is what identifies the package anyway.
580
+ const runAttrs = {
581
+ "senpi.deploy.package": path.basename(pkg.dir),
582
+ "senpi.deploy.instance.count": pkg.instances.length,
583
+ ...(budget !== undefined ? { "senpi.deploy.budget.usd": budget } : {}),
584
+ };
585
+ // The slot claim, guarded. `start` throws when a job is already running — the ordinary case is
586
+ // refused by the `running()` check above, but that check and this call are two statements apart,
587
+ // and `handleStart` does not catch: an unguarded throw returns no response at all, so the CLI
588
+ // takes its "unreadable gateway" path and tells the operator the deploy's state is UNKNOWN over a
589
+ // deploy that plainly never started. Nothing between the two awaits today; that is a property of
590
+ // the current code, not something a future edit is obliged to preserve.
591
+ let meta;
592
+ try {
593
+ meta = ctx.controller.start({ packageId: pkg.id, version: pkg.version, packageDir: pkg.dir, ...(budget !== undefined ? { budget } : {}) }, capMs, (job) => withSpan("deploy.run", { ...runAttrs, "senpi.deploy.id": job.deployId }, async (span) => {
594
+ const report = await ctx.withSenpiClient((client) => runDeploy({
595
+ pkg,
596
+ ...(budget !== undefined ? { budget } : {}),
597
+ ...(decisionModel ? { decisionModel } : {}),
598
+ ...(maxWaitSec !== undefined ? { maxWaitSec } : {}),
599
+ ...(tickWaitSec !== undefined ? { tickWaitSec } : {}),
600
+ priorStrategyIds,
601
+ priorRequested,
602
+ }, buildDeployDeps(ctx, client, job)));
603
+ // M6: `runDeploy` never throws, so `withSpan` never sets ERROR status on a refusal — a
604
+ // refusal sets an OUTCOME attribute instead. Only a genuine escape (a bug) marks it errored.
605
+ span.setAttribute("senpi.deploy.outcome", report.overall);
606
+ const firstRefusal = report.instances
607
+ .flatMap((instance) => Object.values(instance.steps))
608
+ .find((step) => step.status === "refused");
609
+ const code = extractTaxonomyCode(firstRefusal?.detail);
610
+ if (code)
611
+ span.setAttribute("senpi.deploy.refusal.code", code);
612
+ // The SOFT calculated-minimum tier rides ATTRIBUTES, deliberately never
613
+ // `senpi.deploy.refusal.code` and never a span error: it warned, the deploy proceeded, and
614
+ // a dashboard that counted it as a refusal would report healthy deploys as failures. What
615
+ // it is worth measuring is how often users size a budget below the authored design.
616
+ //
617
+ // `below_min` is a claim about the funding PLAN, not about wallets that ended up funded: it
618
+ // stays true when the deploy later failed for an unrelated reason, because the budget was
619
+ // still sized too small and that is the population worth counting. The report's note is the
620
+ // surface that distinguishes "funded" from "would have funded"; this attribute does not.
621
+ if (report.minBudget !== undefined) {
622
+ span.setAttribute("senpi.deploy.budget.min_usd", report.minBudget);
623
+ span.setAttribute("senpi.deploy.budget.below_min", report.belowMin === true);
624
+ }
625
+ const warnCode = extractTaxonomyCode(report.minBudgetNote);
626
+ if (warnCode)
627
+ span.setAttribute("senpi.deploy.budget.warn.code", warnCode);
628
+ // Its OWN attribute, not a second write to `warn.code`: the plan-side warn above and this
629
+ // outcome-side one can both hold on one deploy, and sharing a key would silently drop
630
+ // whichever landed first. This is the population worth alerting on — the platform accepted
631
+ // a funded create and landed materially less on the wallet.
632
+ if (report.partialFundNote)
633
+ span.setAttribute("senpi.deploy.budget.partial_fund", true);
634
+ // Its own attribute again, for the same reason: an unreadable funded figure is a distinct
635
+ // population from a measured shortfall, and it is the one where the platform stopped
636
+ // reporting a number at all.
637
+ if (report.fundedUnreadableNote)
638
+ span.setAttribute("senpi.deploy.budget.funded_unreadable", true);
639
+ return report;
640
+ }));
641
+ }
642
+ catch (err) {
643
+ const running = ctx.controller.running();
644
+ refuse({
645
+ code: "CONFLICT",
646
+ message: running
647
+ ? buildInProgressRefusal(running)
648
+ : `[E_DEPLOY_IN_PROGRESS] the deploy slot could not be claimed: ` +
649
+ `${err instanceof Error ? err.message : String(err)}. Nothing was started. ` +
650
+ `Read \`openclaw senpi deploy status\`, then re-run.`,
651
+ });
652
+ return;
653
+ }
654
+ span.setAttribute("senpi.deploy.id", meta.deployId);
655
+ span.setAttribute("senpi.deploy.package", path.basename(pkg.dir));
656
+ span.setAttribute("senpi.deploy.instance.count", pkg.instances.length);
657
+ respond(true, {
658
+ deployId: meta.deployId,
659
+ state: "running",
660
+ phase: "reconcile",
661
+ monitor: "openclaw senpi deploy status",
662
+ });
663
+ }
664
+ async function handleStatus(ctx, params, respond) {
665
+ const deployId = typeof params?.deployId === "string" ? params.deployId : undefined;
666
+ const snapshot = await ctx.controller.status(deployId);
667
+ if (!snapshot) {
668
+ respond(false, undefined, notFound(buildNoJobsRefusal()));
669
+ return;
670
+ }
671
+ if (snapshot.state.status !== "interrupted") {
672
+ respond(true, snapshot);
673
+ return;
674
+ }
675
+ // Journal narration alone would gaslight here: the process died, so the journal's last word is
676
+ // whatever it managed to write. Pair it with a fresh read of what actually exists.
677
+ const live = await readDeployLiveLayer(ctx, snapshot.meta);
678
+ respond(true, {
679
+ ...snapshot,
680
+ live,
681
+ next: buildInterruptedNext(snapshot.meta, live),
682
+ });
683
+ }
684
+ /** Register the internal deploy transport pair. */
685
+ export function registerDeployGatewayMethods(api, ctx) {
686
+ api.registerGatewayMethod("senpi.deploy.start", async (gwCtx) => handleStart(ctx, gwCtx.params, gwCtx.respond), { scope: "operator.write" });
687
+ api.registerGatewayMethod("senpi.deploy.status", async (gwCtx) => handleStatus(ctx, gwCtx.params, gwCtx.respond), { scope: "operator.read" });
688
+ }
689
+ //# sourceMappingURL=register.js.map