@senpi/runtime 3.0.72 → 3.0.73

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 (61) hide show
  1. package/dist/cli/senpi-commands.d.ts.map +1 -1
  2. package/dist/cli/senpi-commands.js +6 -5
  3. package/dist/cli/senpi-commands.js.map +1 -1
  4. package/dist/deploy/deadlines.d.ts +61 -1
  5. package/dist/deploy/deadlines.d.ts.map +1 -1
  6. package/dist/deploy/deadlines.js +85 -2
  7. package/dist/deploy/deadlines.js.map +1 -1
  8. package/dist/deploy/funding.d.ts +38 -0
  9. package/dist/deploy/funding.d.ts.map +1 -1
  10. package/dist/deploy/funding.js +39 -0
  11. package/dist/deploy/funding.js.map +1 -1
  12. package/dist/deploy/job.d.ts.map +1 -1
  13. package/dist/deploy/job.js +7 -1
  14. package/dist/deploy/job.js.map +1 -1
  15. package/dist/deploy/messages.d.ts +603 -0
  16. package/dist/deploy/messages.d.ts.map +1 -0
  17. package/dist/deploy/messages.js +1388 -0
  18. package/dist/deploy/messages.js.map +1 -0
  19. package/dist/deploy/orchestrator.d.ts +8 -0
  20. package/dist/deploy/orchestrator.d.ts.map +1 -1
  21. package/dist/deploy/orchestrator.js +146 -1211
  22. package/dist/deploy/orchestrator.js.map +1 -1
  23. package/dist/deploy/package.d.ts +33 -0
  24. package/dist/deploy/package.d.ts.map +1 -1
  25. package/dist/deploy/package.js +142 -3
  26. package/dist/deploy/package.js.map +1 -1
  27. package/dist/deploy/recheck-commands.d.ts.map +1 -1
  28. package/dist/deploy/recheck-commands.js +21 -3
  29. package/dist/deploy/recheck-commands.js.map +1 -1
  30. package/dist/deploy/register.d.ts +2 -0
  31. package/dist/deploy/register.d.ts.map +1 -1
  32. package/dist/deploy/register.js +23 -6
  33. package/dist/deploy/register.js.map +1 -1
  34. package/dist/deploy/taxonomy.d.ts +19 -0
  35. package/dist/deploy/taxonomy.d.ts.map +1 -1
  36. package/dist/deploy/taxonomy.js +28 -0
  37. package/dist/deploy/taxonomy.js.map +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +230 -176
  40. package/dist/index.js.map +1 -1
  41. package/dist/runtime/env-resolve.d.ts +13 -0
  42. package/dist/runtime/env-resolve.d.ts.map +1 -1
  43. package/dist/runtime/env-resolve.js +14 -1
  44. package/dist/runtime/env-resolve.js.map +1 -1
  45. package/dist/telemetry/metrics.d.ts +7 -0
  46. package/dist/telemetry/metrics.d.ts.map +1 -1
  47. package/dist/telemetry/metrics.js +7 -0
  48. package/dist/telemetry/metrics.js.map +1 -1
  49. package/dist/validate/codes.d.ts +2 -0
  50. package/dist/validate/codes.d.ts.map +1 -1
  51. package/dist/validate/codes.js +7 -0
  52. package/dist/validate/codes.js.map +1 -1
  53. package/dist/validate/run.d.ts.map +1 -1
  54. package/dist/validate/run.js +6 -1
  55. package/dist/validate/run.js.map +1 -1
  56. package/dist/validate/wallet-binding-stage.d.ts +44 -0
  57. package/dist/validate/wallet-binding-stage.d.ts.map +1 -0
  58. package/dist/validate/wallet-binding-stage.js +103 -0
  59. package/dist/validate/wallet-binding-stage.js.map +1 -0
  60. package/openclaw.plugin.json +1 -1
  61. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { SenpiClient } from "./senpi/client.js";
17
17
  import { buildWalletStatusHint } from "./senpi/strategy-status.js";
18
18
  import { DeployJobController } from "./deploy/job.js";
19
19
  import { registerDeployGatewayMethods } from "./deploy/register.js";
20
+ import { INSTALL_DEADLINE_MS, WALLET_INSTALL_IN_FLIGHT_CODE } from "./deploy/deadlines.js";
20
21
  import { AuditQueryError } from "./runtime/audit-query.types.js";
21
22
  import { buildRuntimeStateDir, archiveStateDir } from "./runtime/state-layout.js";
22
23
  import { describeRuntimeEntry } from "./runtime/runtime-descriptor.js";
@@ -137,6 +138,13 @@ const PLUGIN_PACKAGE_NAME = resolvePackageName();
137
138
  * nothing about the decision a caller has to make.
138
139
  */
139
140
  const WALLET_HAS_RUNTIME = "[E_WALLET_HAS_RUNTIME]";
141
+ /**
142
+ * The fence refusal's code, IMPORTED rather than spelled here — `deploy/messages.ts` renders the
143
+ * same code for the same condition on the deploy path, and two module-private literals were one
144
+ * edit away from answering two different codes for one condition. See its doc in `deadlines.ts`
145
+ * for why it lives beside the install bound both copies quote.
146
+ */
147
+ const WALLET_INSTALL_IN_FLIGHT = `[${WALLET_INSTALL_IN_FLIGHT_CODE}]`;
140
148
  /**
141
149
  * The id-collision refusal's code — the third gate on the same `senpi runtime create` path, and
142
150
  * plausibly the one hit most often, since re-running create with the same id lands on it.
@@ -342,6 +350,23 @@ const senpiRuntimePlugin = {
342
350
  scanners: config.scanners ?? [],
343
351
  });
344
352
  };
353
+ /**
354
+ * Wallets with an install IN FLIGHT in this process, keyed by lowercased address.
355
+ *
356
+ * IN MEMORY, never persisted — the same reasoning `deploy/job.ts` gives for the single-flight
357
+ * slot. `installRuntimeCore` is in-process work, so a pending install cannot outlive this
358
+ * process: if the gateway dies mid-install, the install dies with it and the row it left
359
+ * behind is the ordinary "registry row with no live handle" case that deploy already handles by
360
+ * deleting and reinstalling. A persisted marker would need a liveness heuristic to tell those
361
+ * apart, and would wedge every install on the box after one crash.
362
+ *
363
+ * What it prevents: a deploy whose wait on an install expired (or whose job was force-
364
+ * terminated) frees its slot while the install runs on. Without this, the next deploy finds the
365
+ * row, sees no live handle, deletes it and reinstalls — and the first install then lands on top,
366
+ * clobbering `runtimeHandles` under a runtime id both installs derive identically. Two exit
367
+ * engines, one margin account: the exact state `[E_WALLET_HAS_RUNTIME]` exists to prevent.
368
+ */
369
+ const installsInFlight = new Map();
345
370
  // Dedicated logger for the runtime-api subtree — required because the
346
371
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
347
372
  // Logger contract used by `runtime-api/` includes it.
@@ -1015,6 +1040,7 @@ const senpiRuntimePlugin = {
1015
1040
  const resolvedId = id && id.length > 0
1016
1041
  ? (sanitizeId(id) || "runtime")
1017
1042
  : deriveRuntimeId(runtimeName, wallet);
1043
+ const walletKey = (wallet ?? "").trim().toLowerCase();
1018
1044
  if (hasId(registry, resolvedId)) {
1019
1045
  return { ok: false, error: toError(`${RUNTIME_ID_TAKEN} A runtime with id "${resolvedId}" already exists. Use a different id (e.g. --runtime-id another-name) or remove the existing one first: openclaw senpi runtime delete ${resolvedId}`) };
1020
1046
  }
@@ -1039,9 +1065,28 @@ const senpiRuntimePlugin = {
1039
1065
  * both, because the registry stores `strategy.wallet` verbatim from the YAML and a
1040
1066
  * checksummed address is the same margin account as its lowercase spelling.
1041
1067
  */
1042
- if (wallet) {
1043
- const walletKey = wallet.toLowerCase();
1044
- const liveId = [...runtimeHandles].find(([, handle]) => (handle.wallet ?? "").toLowerCase() === walletKey)?.[0];
1068
+ // Gated on the FOLDED key, not on the raw field. `strategy.wallet` only has to be
1069
+ // `z.string().min(1)`, so a whitespace-only value reaches here with `walletKey === ""` — and
1070
+ // an empty key is not an identity: the live-handle compare below folds the same way, so `""`
1071
+ // matched every handle carrying no wallet at all and refused the install with
1072
+ // `[E_WALLET_HAS_RUNTIME]` naming an unrelated runtime. (`findByWallet` already refuses to
1073
+ // match on an empty address for exactly this reason, and the fence is keyed on `walletKey`
1074
+ // too, so a blank one is never set.) Nothing is skipped that could have matched.
1075
+ if (walletKey) {
1076
+ const inFlight = installsInFlight.get(walletKey);
1077
+ if (inFlight) {
1078
+ const heldSec = Math.round((Date.now() - inFlight.startedAtMs) / 1000);
1079
+ const boundSec = Math.round(INSTALL_DEADLINE_MS / 1000);
1080
+ return { ok: false, error: toError(`${WALLET_INSTALL_IN_FLIGHT} An install is already running on wallet "${wallet}" ` +
1081
+ `(runtime "${inFlight.runtimeId}", started ${heldSec}s ago) in this gateway. Installing a ` +
1082
+ `second runtime on the same wallet would put two exit engines on one margin account. ` +
1083
+ `Nothing was installed and nothing was changed. This normally clears on its own once that ` +
1084
+ `install finishes. A deploy's own install bound is ${boundSec}s — well past that, this install ` +
1085
+ `is more likely wedged than merely slow, and if so the fence on this wallet will not clear ` +
1086
+ `until this gateway process restarts. Check what it produced before installing anything here: ` +
1087
+ `openclaw senpi deploy status, then openclaw senpi runtime list.`) };
1088
+ }
1089
+ const liveId = [...runtimeHandles].find(([, handle]) => (handle.wallet ?? "").trim().toLowerCase() === walletKey)?.[0];
1045
1090
  // Which runtime holds this wallet, from either read. The live half matches on
1046
1091
  // `handle.wallet`, the registry half on `entry.wallet`, so the id is what they have in
1047
1092
  // common — and it is the id, not which read matched, that the two messages differ on.
@@ -1072,197 +1117,205 @@ const senpiRuntimePlugin = {
1072
1117
  `openclaw senpi runtime delete --id ${holderId}`) };
1073
1118
  }
1074
1119
  }
1075
- // Best-effort wallet ownership validation: calls the MCP server to check
1076
- // whether the wallet belongs to the authenticated user's strategies.
1077
- // If the MCP call fails (network issue, bad apiKey, server down), we warn
1078
- // and allow the install to proceed rather than blocking the user.
1079
- if (wallet) {
1080
- try {
1081
- const client = mcpTransport
1082
- ? new SenpiClient(mcpTransport)
1083
- : new SenpiClient({ apiKey });
1084
- if (!mcpTransport)
1085
- await client.connect();
1120
+ if (walletKey)
1121
+ installsInFlight.set(walletKey, { runtimeId: resolvedId, startedAtMs: Date.now() });
1122
+ try {
1123
+ // Best-effort wallet ownership validation: calls the MCP server to check
1124
+ // whether the wallet belongs to the authenticated user's strategies.
1125
+ // If the MCP call fails (network issue, bad apiKey, server down), we warn
1126
+ // and allow the install to proceed rather than blocking the user.
1127
+ if (wallet) {
1086
1128
  try {
1087
- const strategies = await client.listStrategies({ strategyAddresses: [wallet] });
1088
- const match = strategies.find((s) => s.strategyWalletAddress.toLowerCase() === wallet.toLowerCase());
1089
- if (!match) {
1090
- pluginLogger.info("[senpi-runtime] Wallet validation failed: not owned", { wallet });
1091
- // `return` triggers the inner `finally` (disconnect) before exiting.
1092
- return { ok: false, error: toError(`Strategy wallet "${wallet}" (YAML field: strategy.wallet) was not found in your Senpi account's strategies. ` +
1093
- `Check the address for typos. ` +
1094
- `To list all your strategies and their wallet addresses, call the MCP tool: strategy_list (no arguments needed). ` +
1095
- `Each result includes "strategyWalletAddress" — use that value as strategy.wallet in your YAML.`) };
1129
+ const client = mcpTransport
1130
+ ? new SenpiClient(mcpTransport)
1131
+ : new SenpiClient({ apiKey });
1132
+ if (!mcpTransport)
1133
+ await client.connect();
1134
+ try {
1135
+ const strategies = await client.listStrategies({ strategyAddresses: [wallet] });
1136
+ const match = strategies.find((s) => s.strategyWalletAddress.toLowerCase() === wallet.toLowerCase());
1137
+ if (!match) {
1138
+ pluginLogger.info("[senpi-runtime] Wallet validation failed: not owned", { wallet });
1139
+ // `return` triggers the inner `finally` (disconnect) before exiting.
1140
+ return { ok: false, error: toError(`Strategy wallet "${wallet}" (YAML field: strategy.wallet) was not found in your Senpi account's strategies. ` +
1141
+ `Check the address for typos. ` +
1142
+ `To list all your strategies and their wallet addresses, call the MCP tool: strategy_list (no arguments needed). ` +
1143
+ `Each result includes "strategyWalletAddress" — use that value as strategy.wallet in your YAML.`) };
1144
+ }
1145
+ if (match.status !== "ACTIVE") {
1146
+ pluginLogger.info("[senpi-runtime] Wallet validation failed: strategy not active", { wallet, status: match.status, strategyId: match.id });
1147
+ return { ok: false, error: toError(
1148
+ // H7/D-10: the status vocabulary lives in one module, shared with deploy's
1149
+ // reconcile. This gate's private copy was already missing TERMINATED and
1150
+ // CLOSING_DONE, so a gone strategy fell to the generic "only ACTIVE" hint.
1151
+ `Strategy wallet "${wallet}" (YAML field: strategy.wallet) has status "${match.status}" — a runtime requires ACTIVE status. ` +
1152
+ buildWalletStatusHint(match.status) +
1153
+ ` To check the latest status, call the MCP tool: strategy_list.`) };
1154
+ }
1155
+ pluginLogger.info("[senpi-runtime] Wallet validation passed", { wallet, strategyId: match.id, status: match.status });
1096
1156
  }
1097
- if (match.status !== "ACTIVE") {
1098
- pluginLogger.info("[senpi-runtime] Wallet validation failed: strategy not active", { wallet, status: match.status, strategyId: match.id });
1099
- return { ok: false, error: toError(
1100
- // H7/D-10: the status vocabulary lives in one module, shared with deploy's
1101
- // reconcile. This gate's private copy was already missing TERMINATED and
1102
- // CLOSING_DONE, so a gone strategy fell to the generic "only ACTIVE" hint.
1103
- `Strategy wallet "${wallet}" (YAML field: strategy.wallet) has status "${match.status}" — a runtime requires ACTIVE status. ` +
1104
- buildWalletStatusHint(match.status) +
1105
- ` To check the latest status, call the MCP tool: strategy_list.`) };
1157
+ finally {
1158
+ if (!mcpTransport)
1159
+ await client.disconnect().catch(() => { });
1106
1160
  }
1107
- pluginLogger.info("[senpi-runtime] Wallet validation passed", { wallet, strategyId: match.id, status: match.status });
1108
1161
  }
1109
- finally {
1110
- if (!mcpTransport)
1111
- await client.disconnect().catch(() => { });
1162
+ catch (err) {
1163
+ // Soft-fail: validation is best-effort. If connect() or listStrategies()
1164
+ // throws, log a warning and let the install proceed.
1165
+ const msg = err instanceof Error ? err.message : String(err);
1166
+ pluginLogger.warn("[senpi-runtime] Could not validate strategy wallet ownership", { wallet, err: msg });
1112
1167
  }
1113
1168
  }
1114
- catch (err) {
1115
- // Soft-fail: validation is best-effort. If connect() or listStrategies()
1116
- // throws, log a warning and let the install proceed.
1117
- const msg = err instanceof Error ? err.message : String(err);
1118
- pluginLogger.warn("[senpi-runtime] Could not validate strategy wallet ownership", { wallet, err: msg });
1169
+ const entry = hasPath
1170
+ ? { id: resolvedId, runtimeYamlPath, schemaVersion: parsedSchemaVersion, wallet }
1171
+ : {
1172
+ id: resolvedId,
1173
+ runtimeYamlContent: runtimeYamlContent,
1174
+ schemaVersion: parsedSchemaVersion,
1175
+ wallet,
1176
+ // Persist the recipe dir so a gateway restart can resolve an
1177
+ // external scanner's relative `path: ./` (content installs drop it).
1178
+ ...(runtimeYamlDir ? { runtimeYamlDir } : {}),
1179
+ };
1180
+ const valid = validateEntry(entry);
1181
+ if (!valid.ok) {
1182
+ return { ok: false, error: toError(valid.error) };
1119
1183
  }
1120
- }
1121
- const entry = hasPath
1122
- ? { id: resolvedId, runtimeYamlPath, schemaVersion: parsedSchemaVersion, wallet }
1123
- : {
1124
- id: resolvedId,
1125
- runtimeYamlContent: runtimeYamlContent,
1126
- schemaVersion: parsedSchemaVersion,
1127
- wallet,
1128
- // Persist the recipe dir so a gateway restart can resolve an
1129
- // external scanner's relative `path: ./` (content installs drop it).
1130
- ...(runtimeYamlDir ? { runtimeYamlDir } : {}),
1131
- };
1132
- const valid = validateEntry(entry);
1133
- if (!valid.ok) {
1134
- return { ok: false, error: toError(valid.error) };
1135
- }
1136
- registry.runtimes.push(entry);
1137
- await writeRegistry(baseStateDir, registry);
1138
- /**
1139
- * The unwired verdict for THIS install, tracked locally rather than read back out of
1140
- * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
1141
- * from an earlier runtime that happened to share the id. Set at each of the three sites that
1142
- * leave the runtime without live entry scanners; cleared by the wire-success path.
1143
- */
1144
- let unwiredPhase;
1145
- try {
1146
- const scannerProviders = await createScannerProvidersForRun({
1147
- mcpTransport,
1148
- apiKey,
1149
- });
1150
- const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1151
- const handle = await run(pathOrContent, {
1152
- fromPath: hasPath,
1153
- stateDir: entryStateDir,
1154
- mcpTransport,
1155
- apiKey,
1156
- scannerProviders,
1157
- runtimeId: resolvedId,
1158
- });
1159
- runtimeHandles.set(resolvedId, handle);
1160
- if (parsedConfig)
1161
- noteTelemetryRuntimeConfig(resolvedId, parsedConfig);
1162
- else
1163
- noteTelemetryRuntime(resolvedId, wallet);
1164
- // Registry create fact — distinct from the runtime.started boot transition (which also fires
1165
- // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
1166
- emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
1167
- // ── hot-install supervised scanner wire ───────────────────────────────
1168
- // Live-install path: used when a runtime is registered after boot via
1169
- // senpi.installRuntime, while the API is already listening — the
1170
- // counterpart to the boot-time `launcher` above. A runtime takes exactly
1171
- // one path: boot-present → `launcher` (deferred start); installed live →
1172
- // `hotLauncher` (immediate start). The two are mutually exclusive because
1173
- // installRuntime rejects any id/wallet already in the registry before
1174
- // wiring (the hasId guard), so a boot-present runtime never re-enters here.
1175
- // The API is already listening at this point, so we wire and start
1176
- // immediately (no deferred start needed). Any failure is swallowed so
1177
- // the install itself is not rolled back — the runtime is already up.
1178
- // Gate on the same API-disabled flag as the boot path: when the HTTP API
1179
- // is off (a test-only seam), the /signals route is never mounted, so
1180
- // starting scanners would only spawn children that POST to a closed port.
1181
- if (parsedConfig &&
1182
- sharedIntakeContext &&
1183
- process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
1184
- try {
1185
- const apiConfig = loadApiConfig(api.pluginConfig);
1186
- const scaffoldPackageDir = fileURLToPath(new URL("./scanners/external", import.meta.url));
1187
- const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1188
- const hotLauncher = wireRuntime({
1189
- runtimeConfig: parsedConfig,
1190
- recipeDir: hasPath ? dirname(pathOrContent) : runtimeYamlDir,
1191
- runtimeId: resolvedId,
1192
- wallet,
1193
- sharedCtx: sharedIntakeContext,
1194
- runtimeHandles,
1195
- intakeUrl: intakeUrlFor(apiConfig.host, apiConfig.port),
1196
- scaffoldPackageDir,
1197
- runtimeStateDir: entryStateDir,
1198
- logger: apiLogger,
1199
- });
1200
- if (hotLauncher) {
1201
- try {
1202
- await hotLauncher.start();
1203
- launchers.set(resolvedId, hotLauncher);
1204
- unwiredRuntimes.delete(resolvedId);
1205
- unwiredPhase = undefined;
1206
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1207
- unwired: false,
1208
- rows: hotLauncher.describeSupervision(),
1209
- }));
1210
- }
1211
- catch (startErr) {
1212
- await hotLauncher.stop().catch(() => { });
1213
- unwiredRuntimes.set(resolvedId, "install_launch");
1214
- unwiredPhase = "install_launch";
1215
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1216
- unwired: true,
1217
- unwiredPhase: "install_launch",
1218
- rows: [],
1219
- }));
1220
- eventScannersUnwired("install_launch", resolvedId, wallet, appendCause("[senpi-runtime] hot-install supervised scanner launch failed — runtime running without it", startErr), startErr);
1184
+ registry.runtimes.push(entry);
1185
+ await writeRegistry(baseStateDir, registry);
1186
+ /**
1187
+ * The unwired verdict for THIS install, tracked locally rather than read back out of
1188
+ * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
1189
+ * from an earlier runtime that happened to share the id. Set at each of the three sites that
1190
+ * leave the runtime without live entry scanners; cleared by the wire-success path.
1191
+ */
1192
+ let unwiredPhase;
1193
+ try {
1194
+ const scannerProviders = await createScannerProvidersForRun({
1195
+ mcpTransport,
1196
+ apiKey,
1197
+ });
1198
+ const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1199
+ const handle = await run(pathOrContent, {
1200
+ fromPath: hasPath,
1201
+ stateDir: entryStateDir,
1202
+ mcpTransport,
1203
+ apiKey,
1204
+ scannerProviders,
1205
+ runtimeId: resolvedId,
1206
+ });
1207
+ runtimeHandles.set(resolvedId, handle);
1208
+ if (parsedConfig)
1209
+ noteTelemetryRuntimeConfig(resolvedId, parsedConfig);
1210
+ else
1211
+ noteTelemetryRuntime(resolvedId, wallet);
1212
+ // Registry create fact — distinct from the runtime.started boot transition (which also fires
1213
+ // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
1214
+ emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
1215
+ // ── hot-install supervised scanner wire ───────────────────────────────
1216
+ // Live-install path: used when a runtime is registered after boot via
1217
+ // senpi.installRuntime, while the API is already listening — the
1218
+ // counterpart to the boot-time `launcher` above. A runtime takes exactly
1219
+ // one path: boot-present → `launcher` (deferred start); installed live →
1220
+ // `hotLauncher` (immediate start). The two are mutually exclusive because
1221
+ // installRuntime rejects any id/wallet already in the registry before
1222
+ // wiring (the hasId guard), so a boot-present runtime never re-enters here.
1223
+ // The API is already listening at this point, so we wire and start
1224
+ // immediately (no deferred start needed). Any failure is swallowed so
1225
+ // the install itself is not rolled back — the runtime is already up.
1226
+ // Gate on the same API-disabled flag as the boot path: when the HTTP API
1227
+ // is off (a test-only seam), the /signals route is never mounted, so
1228
+ // starting scanners would only spawn children that POST to a closed port.
1229
+ if (parsedConfig &&
1230
+ sharedIntakeContext &&
1231
+ process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
1232
+ try {
1233
+ const apiConfig = loadApiConfig(api.pluginConfig);
1234
+ const scaffoldPackageDir = fileURLToPath(new URL("./scanners/external", import.meta.url));
1235
+ const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1236
+ const hotLauncher = wireRuntime({
1237
+ runtimeConfig: parsedConfig,
1238
+ recipeDir: hasPath ? dirname(pathOrContent) : runtimeYamlDir,
1239
+ runtimeId: resolvedId,
1240
+ wallet,
1241
+ sharedCtx: sharedIntakeContext,
1242
+ runtimeHandles,
1243
+ intakeUrl: intakeUrlFor(apiConfig.host, apiConfig.port),
1244
+ scaffoldPackageDir,
1245
+ runtimeStateDir: entryStateDir,
1246
+ logger: apiLogger,
1247
+ });
1248
+ if (hotLauncher) {
1249
+ try {
1250
+ await hotLauncher.start();
1251
+ launchers.set(resolvedId, hotLauncher);
1252
+ unwiredRuntimes.delete(resolvedId);
1253
+ unwiredPhase = undefined;
1254
+ runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1255
+ unwired: false,
1256
+ rows: hotLauncher.describeSupervision(),
1257
+ }));
1258
+ }
1259
+ catch (startErr) {
1260
+ await hotLauncher.stop().catch(() => { });
1261
+ unwiredRuntimes.set(resolvedId, "install_launch");
1262
+ unwiredPhase = "install_launch";
1263
+ runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1264
+ unwired: true,
1265
+ unwiredPhase: "install_launch",
1266
+ rows: [],
1267
+ }));
1268
+ eventScannersUnwired("install_launch", resolvedId, wallet, appendCause("[senpi-runtime] hot-install supervised scanner launch failed — runtime running without it", startErr), startErr);
1269
+ }
1221
1270
  }
1222
1271
  }
1272
+ catch (wireErr) {
1273
+ // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1274
+ // across the result boundary (payload.unwired) so a funding caller can refuse it.
1275
+ unwiredRuntimes.set(resolvedId, "install_wire");
1276
+ unwiredPhase = "install_wire";
1277
+ runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1278
+ unwired: true,
1279
+ unwiredPhase: "install_wire",
1280
+ rows: [],
1281
+ }));
1282
+ eventScannersUnwired("install_wire", resolvedId, wallet, appendCause("[senpi-runtime] hot-install wiring failed — runtime running without supervised scanners", wireErr), wireErr);
1283
+ }
1223
1284
  }
1224
- catch (wireErr) {
1225
- // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1226
- // across the result boundary (payload.unwired) so a funding caller can refuse it.
1227
- unwiredRuntimes.set(resolvedId, "install_wire");
1228
- unwiredPhase = "install_wire";
1285
+ else if (parsedConfig && !sharedIntakeContext && selectScanners(parsedConfig).length > 0) {
1286
+ // Supervised intake is DISABLED for the whole box (corrupt acceptance record).
1287
+ // The wire block above is skipped, so this hot-installed runtime declares
1288
+ // external scanners but has NO live entry scanners. Record that fail-closed
1289
+ // state (state only, no new event) so listRuntimes/health don't lie.
1290
+ unwiredRuntimes.set(resolvedId, "no_intake");
1291
+ unwiredPhase = "no_intake";
1229
1292
  runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1230
1293
  unwired: true,
1231
- unwiredPhase: "install_wire",
1294
+ unwiredPhase: "no_intake",
1232
1295
  rows: [],
1233
1296
  }));
1234
- eventScannersUnwired("install_wire", resolvedId, wallet, appendCause("[senpi-runtime] hot-install wiring failed — runtime running without supervised scanners", wireErr), wireErr);
1235
1297
  }
1298
+ // ──────────────────────────────────────────────────────────────────────
1299
+ return {
1300
+ ok: true,
1301
+ payload: {
1302
+ id: resolvedId,
1303
+ name: runtimeName,
1304
+ wallet,
1305
+ ...(unwiredPhase ? { unwired: unwiredPhase } : {}),
1306
+ },
1307
+ };
1236
1308
  }
1237
- else if (parsedConfig && !sharedIntakeContext && selectScanners(parsedConfig).length > 0) {
1238
- // Supervised intake is DISABLED for the whole box (corrupt acceptance record).
1239
- // The wire block above is skipped, so this hot-installed runtime declares
1240
- // external scanners but has NO live entry scanners. Record that fail-closed
1241
- // state (state only, no new event) so listRuntimes/health don't lie.
1242
- unwiredRuntimes.set(resolvedId, "no_intake");
1243
- unwiredPhase = "no_intake";
1244
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1245
- unwired: true,
1246
- unwiredPhase: "no_intake",
1247
- rows: [],
1248
- }));
1309
+ catch (err) {
1310
+ const next = registry.runtimes.filter((e) => e.id !== resolvedId);
1311
+ await writeRegistry(baseStateDir, { ...registry, runtimes: next });
1312
+ const msg = err instanceof Error ? err.message : String(err);
1313
+ return { ok: false, error: toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`) };
1249
1314
  }
1250
- // ──────────────────────────────────────────────────────────────────────
1251
- return {
1252
- ok: true,
1253
- payload: {
1254
- id: resolvedId,
1255
- name: runtimeName,
1256
- wallet,
1257
- ...(unwiredPhase ? { unwired: unwiredPhase } : {}),
1258
- },
1259
- };
1260
1315
  }
1261
- catch (err) {
1262
- const next = registry.runtimes.filter((e) => e.id !== resolvedId);
1263
- await writeRegistry(baseStateDir, { ...registry, runtimes: next });
1264
- const msg = err instanceof Error ? err.message : String(err);
1265
- return { ok: false, error: toUnavailable(`Could not start the runtime. This is usually due to invalid or incomplete YAML.\n\n${msg}`) };
1316
+ finally {
1317
+ if (walletKey)
1318
+ installsInFlight.delete(walletKey);
1266
1319
  }
1267
1320
  }
1268
1321
  /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
@@ -1466,6 +1519,7 @@ const senpiRuntimePlugin = {
1466
1519
  // it "running — NO ENTRY SCANNERS"), and reporting `install: skipped` over it would be the
1467
1520
  // same clean line over a runtime that cannot tick.
1468
1521
  runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
1522
+ installPendingOnWallet: async (wallet) => installsInFlight.has(wallet.trim().toLowerCase()),
1469
1523
  logger: { info: (line) => api.logger.info(`[senpi-runtime] ${line}`) },
1470
1524
  });
1471
1525
  /**