@senpi/runtime 3.0.65 → 3.0.67

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 (94) hide show
  1. package/dist/actions/event-derivation.d.ts.map +1 -1
  2. package/dist/actions/event-derivation.js +2 -0
  3. package/dist/actions/event-derivation.js.map +1 -1
  4. package/dist/cli/deploy-render.d.ts +3 -1
  5. package/dist/cli/deploy-render.d.ts.map +1 -1
  6. package/dist/cli/deploy-render.js +233 -11
  7. package/dist/cli/deploy-render.js.map +1 -1
  8. package/dist/cli/guide-commands.d.ts.map +1 -1
  9. package/dist/cli/guide-commands.js +9 -7
  10. package/dist/cli/guide-commands.js.map +1 -1
  11. package/dist/cli/senpi-commands.js +4 -4
  12. package/dist/cli/senpi-commands.js.map +1 -1
  13. package/dist/deploy/deadlines.d.ts +3 -3
  14. package/dist/deploy/deadlines.js +3 -3
  15. package/dist/deploy/job.d.ts +16 -0
  16. package/dist/deploy/job.d.ts.map +1 -1
  17. package/dist/deploy/job.js +7 -1
  18. package/dist/deploy/job.js.map +1 -1
  19. package/dist/deploy/orchestrator.d.ts +2 -1
  20. package/dist/deploy/orchestrator.d.ts.map +1 -1
  21. package/dist/deploy/orchestrator.js +402 -84
  22. package/dist/deploy/orchestrator.js.map +1 -1
  23. package/dist/deploy/proof-gate.d.ts.map +1 -1
  24. package/dist/deploy/proof-gate.js +8 -3
  25. package/dist/deploy/proof-gate.js.map +1 -1
  26. package/dist/deploy/register.js +1 -1
  27. package/dist/health/types.d.ts +6 -0
  28. package/dist/health/types.d.ts.map +1 -1
  29. package/dist/health/types.js.map +1 -1
  30. package/dist/index.d.ts +16 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +234 -21
  33. package/dist/index.js.map +1 -1
  34. package/dist/runtime/runtime.d.ts.map +1 -1
  35. package/dist/runtime/runtime.js +7 -1
  36. package/dist/runtime/runtime.js.map +1 -1
  37. package/dist/scanners/engine/engine.d.ts +5 -0
  38. package/dist/scanners/engine/engine.d.ts.map +1 -1
  39. package/dist/scanners/engine/engine.js +1 -0
  40. package/dist/scanners/engine/engine.js.map +1 -1
  41. package/dist/scanners/runtime-module.d.ts.map +1 -1
  42. package/dist/scanners/runtime-module.js +5 -0
  43. package/dist/scanners/runtime-module.js.map +1 -1
  44. package/dist/senpi/client.d.ts.map +1 -1
  45. package/dist/senpi/client.js +72 -5
  46. package/dist/senpi/client.js.map +1 -1
  47. package/dist/senpi/types.d.ts +18 -0
  48. package/dist/senpi/types.d.ts.map +1 -1
  49. package/dist/telemetry/event-builders.d.ts +12 -0
  50. package/dist/telemetry/event-builders.d.ts.map +1 -1
  51. package/dist/telemetry/event-builders.js +2 -0
  52. package/dist/telemetry/event-builders.js.map +1 -1
  53. package/dist/telemetry/event-store.d.ts +6 -0
  54. package/dist/telemetry/event-store.d.ts.map +1 -1
  55. package/dist/telemetry/event-store.js +6 -6
  56. package/dist/telemetry/event-store.js.map +1 -1
  57. package/dist/telemetry/live-telemetry.d.ts +153 -0
  58. package/dist/telemetry/live-telemetry.d.ts.map +1 -0
  59. package/dist/telemetry/live-telemetry.js +471 -0
  60. package/dist/telemetry/live-telemetry.js.map +1 -0
  61. package/dist/telemetry/scanner-stats.d.ts +136 -0
  62. package/dist/telemetry/scanner-stats.d.ts.map +1 -0
  63. package/dist/telemetry/scanner-stats.js +304 -0
  64. package/dist/telemetry/scanner-stats.js.map +1 -0
  65. package/dist/telemetry/strategy-topology.d.ts +99 -0
  66. package/dist/telemetry/strategy-topology.d.ts.map +1 -0
  67. package/dist/telemetry/strategy-topology.js +93 -0
  68. package/dist/telemetry/strategy-topology.js.map +1 -0
  69. package/dist/telemetry/telemetry-publisher.d.ts +63 -0
  70. package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
  71. package/dist/telemetry/telemetry-publisher.js +229 -0
  72. package/dist/telemetry/telemetry-publisher.js.map +1 -0
  73. package/dist/telemetry/timeline-buffer.d.ts +95 -0
  74. package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
  75. package/dist/telemetry/timeline-buffer.js +237 -0
  76. package/dist/telemetry/timeline-buffer.js.map +1 -0
  77. package/dist/telemetry/tracing.d.ts +9 -0
  78. package/dist/telemetry/tracing.d.ts.map +1 -1
  79. package/dist/telemetry/tracing.js +36 -0
  80. package/dist/telemetry/tracing.js.map +1 -1
  81. package/dist/utils/event-catalog.d.ts +8 -0
  82. package/dist/utils/event-catalog.d.ts.map +1 -1
  83. package/dist/utils/event-catalog.js.map +1 -1
  84. package/dist/utils/ulid.d.ts +9 -0
  85. package/dist/utils/ulid.d.ts.map +1 -0
  86. package/dist/utils/ulid.js +43 -0
  87. package/dist/utils/ulid.js.map +1 -0
  88. package/dist/validate/proof.js +4 -1
  89. package/dist/validate/proof.js.map +1 -1
  90. package/dist/validate/run.d.ts.map +1 -1
  91. package/dist/validate/run.js +4 -1
  92. package/dist/validate/run.js.map +1 -1
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ import { createIntakeContext } from "./runtime-api/intake/intake-context.js";
56
56
  import { wireRuntime, intakeUrlFor, selectScanners } from "./runtime-api/intake/intake-mount.js";
57
57
  import { ingestDispatchMutex, ingestDispatchKey } from "./runtime-api/intake/ingest-mutex.js";
58
58
  import { loadRuntimeConfig } from "./runtime/load-runtime-config.js";
59
+ import { createLiveTelemetry } from "./telemetry/live-telemetry.js";
59
60
  /** Initialize all core features (DSL, risk, health) on the given event bus. */
60
61
  export function initCore(bus) {
61
62
  initDsl(bus);
@@ -116,6 +117,35 @@ const PLUGIN_MANIFEST_VERSION = readPluginManifestVersion();
116
117
  * See `runtime/auto-update/resolve-package-name.ts`.
117
118
  */
118
119
  const PLUGIN_PACKAGE_NAME = resolvePackageName();
120
+ /**
121
+ * The taxonomy code both one-runtime-per-wallet refusals lead with.
122
+ *
123
+ * `openclaw gateway call` collapses two different worlds into one process status — it catches
124
+ * whatever its RPC throws, prints `Gateway call failed: <error>` and exits 1, whether the gateway
125
+ * was unreachable or the gateway ANSWERED with a refusal. So the CLI separates them by message
126
+ * taxonomy instead: `isRefusalMessage` (`deploy/taxonomy.ts`) reads a leading bracketed code, and
127
+ * no transport failure carries one. These two refusals were bare prose, so the classifier could not
128
+ * see them and a deliberate policy decision reached its reader framed as a broken gateway — the one
129
+ * conclusion that is false here, and the one that sends whoever reads it to look at infrastructure
130
+ * instead of at their own action.
131
+ *
132
+ * ONE code for both branches. The condition is identical — this wallet already has a runtime, and
133
+ * two exit engines on one wallet's margin disagree about what is protected and stop each other's
134
+ * positions out on different floors. What differs between the two messages is only how much this
135
+ * gateway could see (a live handle, or a registry row), which the prose states and which changes
136
+ * nothing about the decision a caller has to make.
137
+ */
138
+ const WALLET_HAS_RUNTIME = "[E_WALLET_HAS_RUNTIME]";
139
+ /**
140
+ * The id-collision refusal's code — the third gate on the same `senpi runtime create` path, and
141
+ * plausibly the one hit most often, since re-running create with the same id lands on it.
142
+ *
143
+ * A SEPARATE code from `WALLET_HAS_RUNTIME`, where those two share one: the condition here is an
144
+ * id already taken, not a wallet already under management, and the remedy differs with it — pick
145
+ * another id, rather than stop or inspect the runtime holding a margin account. Nothing about
146
+ * anyone's money is being claimed, which is precisely why it must not answer with the wallet code.
147
+ */
148
+ const RUNTIME_ID_TAKEN = "[E_RUNTIME_ID_TAKEN]";
119
149
  /**
120
150
  * OpenClaw plugin entry.
121
151
  * Boots from internal registry (installed_runtimes.json) in stateDir; does not read runtime list from config.
@@ -287,10 +317,140 @@ const senpiRuntimePlugin = {
287
317
  // cleared on a successful wire/install. senpi.listRuntimes reads it to render
288
318
  // "running — NO ENTRY SCANNERS" instead of a bare "running" for a blind runtime.
289
319
  const unwiredRuntimes = new Map();
320
+ // ── Live telemetry ────────────────────────────────────────────────
321
+ // Every runtime this box knows about, running or not, keyed runtimeId. The topology payload has
322
+ // to name a stopped strategy too, so this is populated from the registry rather than from
323
+ // `runtimeHandles` (which holds only the live ones) and is the source of the two facts the
324
+ // registry entry does not carry: the strategy's display name and its declared scanners. Both come
325
+ // from the parsed YAML — `name` is the same `config.name` that `RuntimeHealthStatus.runtimeName`
326
+ // is built from, so a runtime whose config never parsed falls back to its id rather than to a
327
+ // guess, and its scanner list stays empty rather than inventing rows.
328
+ const telemetryStrategies = new Map();
329
+ const noteTelemetryRuntime = (runtimeId, address) => {
330
+ const existing = telemetryStrategies.get(runtimeId);
331
+ telemetryStrategies.set(runtimeId, {
332
+ address,
333
+ name: existing?.name ?? runtimeId,
334
+ scanners: existing?.scanners ?? [],
335
+ });
336
+ };
337
+ const noteTelemetryRuntimeConfig = (runtimeId, config) => {
338
+ telemetryStrategies.set(runtimeId, {
339
+ address: config.strategy?.wallet ?? telemetryStrategies.get(runtimeId)?.address ?? "",
340
+ name: config.name,
341
+ scanners: config.scanners ?? [],
342
+ });
343
+ };
290
344
  // Dedicated logger for the runtime-api subtree — required because the
291
345
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
292
346
  // Logger contract used by `runtime-api/` includes it.
293
347
  const apiLogger = createLogger("[senpi-core][api]");
348
+ /**
349
+ * One gather per publish tick. Health and system state are read together because the two payloads
350
+ * need both: the topology reads health and `runtimeName`, the stats read the per-scanner rows,
351
+ * and `restartCount` / `degradedReason` live only on the health rows while every other scanner
352
+ * field lives only on the system-state rows — so they are joined here on `(runtimeId, scannerId)`.
353
+ */
354
+ const collectTelemetryFacts = async () => {
355
+ const [statuses, states] = await Promise.all([
356
+ collectRuntimeHealthStatuses(runtimeHandles, api.logger),
357
+ collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir),
358
+ ]);
359
+ const healthByRuntime = new Map();
360
+ for (const status of statuses) {
361
+ if (status.runtimeId !== undefined)
362
+ healthByRuntime.set(status.runtimeId, status);
363
+ }
364
+ const scannerState = [];
365
+ // Registration facts per (runtimeId, scannerId), so the topology can report a scanner's live
366
+ // schedule rather than only what its YAML declared.
367
+ const registrations = new Map();
368
+ for (const state of states) {
369
+ const runtimeId = state.runtimeId;
370
+ const healthRows = runtimeId === undefined
371
+ ? []
372
+ : healthByRuntime.get(runtimeId)?.components.scanners.scanners ?? [];
373
+ for (const row of state.components.scanners.state.scanners) {
374
+ const supervision = healthRows.find((r) => r.scannerId === row.scannerId);
375
+ const snapshot = {
376
+ address: row.address,
377
+ scannerId: row.scannerId,
378
+ health: row.health,
379
+ degradedReason: supervision?.degradedReason ?? null,
380
+ enabled: row.enabled,
381
+ inFlightSince: row.inFlightSince,
382
+ lastRunStartedAt: row.lastRunStartedAt,
383
+ lastRunFinishedAt: row.lastRunFinishedAt,
384
+ lastRunStatus: row.lastRunStatus,
385
+ lastRunReason: row.lastRunReason,
386
+ nextRunAt: row.nextRunAt,
387
+ lastAliveAt: row.lastAliveAt,
388
+ // No supervision row means the restart count is unknown, not zero. A zero here would
389
+ // read as "this scanner has never restarted" on a scanner nobody is supervising.
390
+ restartCount: supervision?.restartCount ?? null,
391
+ };
392
+ scannerState.push(snapshot);
393
+ if (runtimeId !== undefined) {
394
+ registrations.set(`${runtimeId}::${row.scannerId}`, {
395
+ ...snapshot,
396
+ scheduleMode: row.scheduleMode,
397
+ configVersion: row.configVersion,
398
+ intervalSeconds: row.intervalSeconds,
399
+ });
400
+ }
401
+ }
402
+ }
403
+ const strategies = [];
404
+ for (const [runtimeId, entry] of telemetryStrategies) {
405
+ const health = healthByRuntime.get(runtimeId);
406
+ const scanners = entry.scanners.map((declared) => {
407
+ const live = registrations.get(`${runtimeId}::${declared.name}`);
408
+ return live === undefined
409
+ ? { entry: declared }
410
+ : {
411
+ entry: declared,
412
+ registration: {
413
+ enabled: live.enabled,
414
+ scheduleMode: live.scheduleMode,
415
+ // The engine's own cadence, which is what the scanner actually polls on — a
416
+ // recipe may omit it and take the scanner definition's default. An external
417
+ // scanner registers zero because it is POSTed to rather than polled, and only
418
+ // the recipe knows the cadence its scaffold was told to tick at.
419
+ intervalSeconds: live.intervalSeconds > 0 ? live.intervalSeconds : declared.interval_seconds ?? null,
420
+ configVersion: live.configVersion,
421
+ },
422
+ };
423
+ });
424
+ strategies.push({
425
+ runtimeId,
426
+ address: entry.address,
427
+ // `runtimeName` is built from the same `config.name`; the indexed value is used because it
428
+ // is available for a stopped runtime too, where there is no health status to read.
429
+ name: health?.runtimeName ?? entry.name,
430
+ scanners,
431
+ });
432
+ }
433
+ return {
434
+ strategies,
435
+ runningRuntimeIds: runtimeHandles,
436
+ unwiredRuntimeIds: unwiredRuntimes,
437
+ readHealth: (runtimeId) => healthByRuntime.get(runtimeId),
438
+ scannerState,
439
+ };
440
+ };
441
+ const liveTelemetry = createLiveTelemetry({ collectFacts: collectTelemetryFacts });
442
+ /**
443
+ * Every gateway method goes through here so the broadcast handle is captured on the FIRST request
444
+ * of any kind. `ctx.context` is the only route to the websocket push layer and it exists only
445
+ * inside a handler — capturing it in the subscribe method alone would mean a box whose client
446
+ * never subscribes can never publish anything, silently.
447
+ */
448
+ const registerGatewayMethod = (name, handler, opts) => {
449
+ api.registerGatewayMethod(name, (ctx) => {
450
+ liveTelemetry.captureBroadcast(ctx, name);
451
+ return handler(ctx);
452
+ }, opts);
453
+ };
294
454
  // A gateway seam failed to wire/launch a runtime's external scanner stack: the runtime is up but
295
455
  // runs blind on internal scanners only. Two records, by design: a first-class `runtime.scanners_unwired`
296
456
  // event (the queryable/alertable fact, by phase) and the migrated exception-form `logger.error`
@@ -498,6 +658,9 @@ const senpiRuntimePlugin = {
498
658
  continue;
499
659
  const pathOrContent = entry.runtimeYamlPath ?? entry.runtimeYamlContent;
500
660
  const entryStateDir = buildRuntimeStateDir(baseStateDir, entry.id, entry.wallet ?? "");
661
+ // Indexed before the boot attempt: a runtime whose start throws is installed but stopped,
662
+ // and the telemetry topology has to be able to say so.
663
+ noteTelemetryRuntime(entry.id, entry.wallet ?? "");
501
664
  const handle = await run(pathOrContent, {
502
665
  fromPath: hasPath,
503
666
  stateDir: entryStateDir,
@@ -520,6 +683,7 @@ const senpiRuntimePlugin = {
520
683
  if (sharedIntakeContext) {
521
684
  try {
522
685
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
686
+ noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
523
687
  // Content-installed runtimes carry their recipe dir in the registry
524
688
  // entry (runtimeYamlDir) so relative scanner paths still resolve
525
689
  // after a restart; path-installed ones derive it from the path.
@@ -571,6 +735,7 @@ const senpiRuntimePlugin = {
571
735
  // event) so listRuntimes/health don't render a bare "running".
572
736
  try {
573
737
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
738
+ noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
574
739
  if (selectScanners(runtimeConfig).length > 0) {
575
740
  unwiredRuntimes.set(entry.id, "no_intake");
576
741
  handle.attachSupervisionSource(() => ({
@@ -672,8 +837,19 @@ const senpiRuntimePlugin = {
672
837
  }
673
838
  }
674
839
  }
840
+ // Publish ticks start only once the runtimes are up, so the first frames describe a real
841
+ // box. Gated by the same switch the skills coordinator uses: unit tests boot `register()`
842
+ // and must not acquire background timers. The subscribe method still answers without them.
843
+ if (process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
844
+ liveTelemetry.start();
845
+ }
675
846
  },
676
847
  async stop() {
848
+ // Live telemetry goes first: it owns a timer and an in-memory buffer, nothing downstream
849
+ // depends on it, and stopping it here guarantees no publish tick reads a half-torn-down
850
+ // runtime handle. Records emitted during the teardown below land in a buffer nobody drains,
851
+ // which is the right trade for a process on its way out.
852
+ liveTelemetry.stop();
677
853
  // Drain the HTTP server FIRST so in-flight POST /signals requests
678
854
  // finish before the underlying runtime they target is torn down.
679
855
  // `apiHandle.close()` never throws.
@@ -839,7 +1015,7 @@ const senpiRuntimePlugin = {
839
1015
  ? (sanitizeId(id) || "runtime")
840
1016
  : deriveRuntimeId(runtimeName, wallet);
841
1017
  if (hasId(registry, resolvedId)) {
842
- return { ok: false, error: toError(`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}`) };
1018
+ 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}`) };
843
1019
  }
844
1020
  /**
845
1021
  * One runtime per wallet address — asked of PERSISTED state, not only of this process.
@@ -875,7 +1051,7 @@ const senpiRuntimePlugin = {
875
1051
  // two reads found it. They agree in practice; the refusal has to agree with the surface
876
1052
  // it sends the reader to, because that is where the reader checks it.
877
1053
  if (runtimeHandles.has(holderId)) {
878
- return { ok: false, error: toError(`A runtime for wallet "${wallet}" is already running (runtime "${holderId}"). ` +
1054
+ return { ok: false, error: toError(`${WALLET_HAS_RUNTIME} A runtime for wallet "${wallet}" is already running (runtime "${holderId}"). ` +
879
1055
  `Stop it first: openclaw senpi runtime delete --id ${holderId}`) };
880
1056
  }
881
1057
  return { ok: false, error: toError(
@@ -886,7 +1062,7 @@ const senpiRuntimePlugin = {
886
1062
  // next restart TRIES. The next step leads with the read, because the operator has to
887
1063
  // find out which of the two runtimes they actually want before removing either, and
888
1064
  // deleting the wrong one leaves a funded wallet watched by nothing.
889
- `A runtime for wallet "${wallet}" is already installed (runtime "${holderId}"). ` +
1065
+ `${WALLET_HAS_RUNTIME} A runtime for wallet "${wallet}" is already installed (runtime "${holderId}"). ` +
890
1066
  `It is not running in this gateway right now, but it is in the registry, so the next ` +
891
1067
  `restart will try to start it — installing a second runtime on the same wallet would ` +
892
1068
  `put two exit engines on one wallet's margin, each tracking its own high-water floor. ` +
@@ -980,6 +1156,10 @@ const senpiRuntimePlugin = {
980
1156
  runtimeId: resolvedId,
981
1157
  });
982
1158
  runtimeHandles.set(resolvedId, handle);
1159
+ if (parsedConfig)
1160
+ noteTelemetryRuntimeConfig(resolvedId, parsedConfig);
1161
+ else
1162
+ noteTelemetryRuntime(resolvedId, wallet);
983
1163
  // Registry create fact — distinct from the runtime.started boot transition (which also fires
984
1164
  // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
985
1165
  emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
@@ -1085,7 +1265,7 @@ const senpiRuntimePlugin = {
1085
1265
  }
1086
1266
  }
1087
1267
  /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
1088
- api.registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1268
+ registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1089
1269
  const respond = ctx.respond;
1090
1270
  const result = await installRuntimeCore(ctx.params);
1091
1271
  if (result.ok)
@@ -1093,7 +1273,7 @@ const senpiRuntimePlugin = {
1093
1273
  else
1094
1274
  respond(false, undefined, result.error);
1095
1275
  }, { scope: "operator.write" });
1096
- api.registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1276
+ registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1097
1277
  const respond = ctx.respond;
1098
1278
  const { readRegistry } = await import("./runtime/strategy-registry.js");
1099
1279
  const registry = await readRegistry(baseStateDir);
@@ -1172,6 +1352,9 @@ const senpiRuntimePlugin = {
1172
1352
  // "running — NO ENTRY SCANNERS" forever. The map is otherwise only cleared on a
1173
1353
  // successful wire/install, which a scanner-less reinstall never reaches.
1174
1354
  unwiredRuntimes.delete(targetId);
1355
+ // Same reuse hazard: a deleted id left in the index would keep publishing a strategy that no
1356
+ // longer exists, and would hand a reinstall the old name and scanner list.
1357
+ telemetryStrategies.delete(targetId);
1175
1358
  // Archive state directory before removing from registry
1176
1359
  if (deletedEntry) {
1177
1360
  const runtimeStateDir = buildRuntimeStateDir(baseStateDir, targetId, deletedEntry.wallet ?? "");
@@ -1198,7 +1381,7 @@ const senpiRuntimePlugin = {
1198
1381
  }));
1199
1382
  return { ok: true, payload: { id: targetId } };
1200
1383
  }
1201
- api.registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1384
+ registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1202
1385
  const respond = ctx.respond;
1203
1386
  const result = await deleteRuntimeCore(ctx.params);
1204
1387
  if (result.ok)
@@ -1292,7 +1475,7 @@ const senpiRuntimePlugin = {
1292
1475
  * producers — slated for removal in Task 5 once the migration counter
1293
1476
  * decays to zero (see `runtime-phase-2-api-plan.md` Section 8).
1294
1477
  */
1295
- api.registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1478
+ registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1296
1479
  const respond = ctx.respond;
1297
1480
  // TODO(s11): wire actual telemetry counter for
1298
1481
  // runtime.deprecated_gateway_ingest_call (event name, properties:
@@ -1368,7 +1551,7 @@ const senpiRuntimePlugin = {
1368
1551
  });
1369
1552
  }
1370
1553
  }, { scope: "operator.write" });
1371
- api.registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1554
+ registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1372
1555
  const respond = ctx.respond;
1373
1556
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1374
1557
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1393,7 +1576,7 @@ const senpiRuntimePlugin = {
1393
1576
  }
1394
1577
  respond(true, { positions });
1395
1578
  }, { scope: "operator.read" });
1396
- api.registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1579
+ registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1397
1580
  const respond = ctx.respond;
1398
1581
  const asset = typeof ctx.params?.asset === "string" ? ctx.params.asset : undefined;
1399
1582
  if (!asset) {
@@ -1434,7 +1617,7 @@ const senpiRuntimePlugin = {
1434
1617
  }
1435
1618
  respond(true, { positions: matches });
1436
1619
  }, { scope: "operator.read" });
1437
- api.registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1620
+ registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1438
1621
  const respond = ctx.respond;
1439
1622
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1440
1623
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1460,7 +1643,7 @@ const senpiRuntimePlugin = {
1460
1643
  closes = closes.slice(0, limit);
1461
1644
  respond(true, { closes });
1462
1645
  }, { scope: "operator.read" });
1463
- api.registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1646
+ registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1464
1647
  const respond = ctx.respond;
1465
1648
  const updater = autoUpdateCoordinator.getSnapshot();
1466
1649
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1488,7 +1671,7 @@ const senpiRuntimePlugin = {
1488
1671
  const statuses = await collectRuntimeHealthStatuses(runtimeHandles, api.logger);
1489
1672
  respond(true, { statuses, updater });
1490
1673
  }, { scope: "operator.read" });
1491
- api.registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1674
+ registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1492
1675
  const respond = ctx.respond;
1493
1676
  const updater = autoUpdateCoordinator.getSnapshot();
1494
1677
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1516,6 +1699,36 @@ const senpiRuntimePlugin = {
1516
1699
  const states = await collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir);
1517
1700
  respond(true, { states, updater });
1518
1701
  }, { scope: "operator.read" });
1702
+ /**
1703
+ * Live telemetry — the one method of the stream, called once per connect.
1704
+ *
1705
+ * It returns the current value of all three broadcast payloads plus the recent record window, so
1706
+ * a client joining a box that has been running for hours renders a full screen immediately
1707
+ * instead of waiting for the next thing to happen. `(afterEpoch, afterSeq)` resumes a client that
1708
+ * already holds part of this epoch's run — a cursor naming any other epoch is dropped, because a
1709
+ * seq from a previous run cannot address these records. `truncated` says when the records between
1710
+ * a cursor of this epoch and the oldest record still held are gone, which nothing in `recent`
1711
+ * alone can express.
1712
+ *
1713
+ * Read-only, like every other inspection method here. The scope that decides who RECEIVES the
1714
+ * broadcasts is a property of the client's own connection, not of this method.
1715
+ */
1716
+ registerGatewayMethod("senpi.telemetry.subscribe", async (ctx) => {
1717
+ const respond = ctx.respond;
1718
+ try {
1719
+ respond(true, await liveTelemetry.snapshot({
1720
+ afterEpoch: ctx.params?.afterEpoch,
1721
+ afterSeq: ctx.params?.afterSeq,
1722
+ }));
1723
+ }
1724
+ catch (err) {
1725
+ api.logger.warn("[senpi-runtime] telemetry subscribe error", { err });
1726
+ respond(false, undefined, {
1727
+ code: "INTERNAL_ERROR",
1728
+ message: `Failed to build the telemetry snapshot: ${formatGatewayError(err)}`,
1729
+ });
1730
+ }
1731
+ }, { scope: "operator.read" });
1519
1732
  /**
1520
1733
  * Action layer — read-only inspection.
1521
1734
  *
@@ -1535,7 +1748,7 @@ const senpiRuntimePlugin = {
1535
1748
  * persisted action-store machinery (`src/actions/store/`) usable
1536
1749
  * from the operator-facing CLI again.
1537
1750
  */
1538
- api.registerGatewayMethod("senpi.listActions", async (ctx) => {
1751
+ registerGatewayMethod("senpi.listActions", async (ctx) => {
1539
1752
  const respond = ctx.respond;
1540
1753
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1541
1754
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1565,7 +1778,7 @@ const senpiRuntimePlugin = {
1565
1778
  }
1566
1779
  respond(true, { actions: merged });
1567
1780
  }, { scope: "operator.read" });
1568
- api.registerGatewayMethod("senpi.getActionState", async (ctx) => {
1781
+ registerGatewayMethod("senpi.getActionState", async (ctx) => {
1569
1782
  const respond = ctx.respond;
1570
1783
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1571
1784
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -1602,7 +1815,7 @@ const senpiRuntimePlugin = {
1602
1815
  });
1603
1816
  }
1604
1817
  }, { scope: "operator.read" });
1605
- api.registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
1818
+ registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
1606
1819
  const respond = ctx.respond;
1607
1820
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1608
1821
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -1670,7 +1883,7 @@ const senpiRuntimePlugin = {
1670
1883
  // reachable on HTTP `/audit`. Passes through the caller-controlled subset
1671
1884
  // of the audit_query contract; resource scoping (wallet → strategyId) is
1672
1885
  // owned by handle.auditQuery, never the caller.
1673
- api.registerGatewayMethod("senpi.auditQuery", async (ctx) => {
1886
+ registerGatewayMethod("senpi.auditQuery", async (ctx) => {
1674
1887
  const respond = ctx.respond;
1675
1888
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1676
1889
  if (!runtimeId) {
@@ -1718,7 +1931,7 @@ const senpiRuntimePlugin = {
1718
1931
  // its state root private, so re-derive it from baseStateDir + the handle's
1719
1932
  // wallet (same helper the writer's stateDir was built with); the writer
1720
1933
  // nests entries under `<root>/<address>/risk-guard/audit.jsonl`.
1721
- api.registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
1934
+ registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
1722
1935
  const respond = ctx.respond;
1723
1936
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1724
1937
  if (!runtimeId) {
@@ -1761,7 +1974,7 @@ const senpiRuntimePlugin = {
1761
1974
  // Per-strategy local domain-event ring (on-disk JSONL) — the trade narrative, queryable locally.
1762
1975
  // Same state-root re-derivation as getRiskAudit: the Runtime keeps its root private, so rebuild it
1763
1976
  // from baseStateDir + the handle's wallet; the writer nests rows under `<root>/strategies/<address>/events/`.
1764
- api.registerGatewayMethod("senpi.getEvents", async (ctx) => {
1977
+ registerGatewayMethod("senpi.getEvents", async (ctx) => {
1765
1978
  const respond = ctx.respond;
1766
1979
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1767
1980
  if (!runtimeId) {
@@ -1818,12 +2031,12 @@ const senpiRuntimePlugin = {
1818
2031
  }
1819
2032
  }, { scope: "operator.read" });
1820
2033
  // ─── Skills manager — read + mutate ──────────────────────────────────────
1821
- api.registerGatewayMethod("senpi.skills.status", async (ctx) => {
2034
+ registerGatewayMethod("senpi.skills.status", async (ctx) => {
1822
2035
  const respond = ctx.respond;
1823
2036
  const snapshot = skillsCoordinator.getSnapshot();
1824
2037
  respond(true, { snapshot });
1825
2038
  }, { scope: "operator.read" });
1826
- api.registerGatewayMethod("senpi.skills.update", async (ctx) => {
2039
+ registerGatewayMethod("senpi.skills.update", async (ctx) => {
1827
2040
  const respond = ctx.respond;
1828
2041
  const name = typeof ctx.params?.name === "string" ? ctx.params.name.trim() : undefined;
1829
2042
  if (!name) {
@@ -1848,7 +2061,7 @@ const senpiRuntimePlugin = {
1848
2061
  });
1849
2062
  }
1850
2063
  }, { scope: "operator.write" });
1851
- api.registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
2064
+ registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
1852
2065
  const respond = ctx.respond;
1853
2066
  try {
1854
2067
  const results = await skillsCoordinator.updateAll();