@senpi/runtime 3.0.108 → 3.0.111

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 (241) hide show
  1. package/dist/actions/action-events.d.ts +21 -7
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +54 -26
  4. package/dist/actions/action-events.js.map +1 -1
  5. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  6. package/dist/actions/action-runtime-module.js +38 -7
  7. package/dist/actions/action-runtime-module.js.map +1 -1
  8. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  9. package/dist/actions/close-position/close-position.action.js +122 -15
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/decision-engine.d.ts.map +1 -1
  12. package/dist/actions/decision-engine.js +15 -2
  13. package/dist/actions/decision-engine.js.map +1 -1
  14. package/dist/actions/event-derivation.d.ts +71 -2
  15. package/dist/actions/event-derivation.d.ts.map +1 -1
  16. package/dist/actions/event-derivation.js +251 -8
  17. package/dist/actions/event-derivation.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +28 -2
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +220 -52
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts +2 -1
  23. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  24. package/dist/actions/position-tracker/position-tracker.action.js +62 -17
  25. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  26. package/dist/actions/skipped-signal-results.d.ts +25 -0
  27. package/dist/actions/skipped-signal-results.d.ts.map +1 -0
  28. package/dist/actions/skipped-signal-results.js +71 -0
  29. package/dist/actions/skipped-signal-results.js.map +1 -0
  30. package/dist/actions/store/types.d.ts +9 -2
  31. package/dist/actions/store/types.d.ts.map +1 -1
  32. package/dist/actions/store/types.js.map +1 -1
  33. package/dist/actions/types.d.ts +13 -0
  34. package/dist/actions/types.d.ts.map +1 -1
  35. package/dist/actions/types.js +13 -0
  36. package/dist/actions/types.js.map +1 -1
  37. package/dist/cli/guide-commands.d.ts.map +1 -1
  38. package/dist/cli/guide-commands.js +3 -0
  39. package/dist/cli/guide-commands.js.map +1 -1
  40. package/dist/constants/dsl/index.d.ts +15 -0
  41. package/dist/constants/dsl/index.d.ts.map +1 -1
  42. package/dist/constants/dsl/index.js +15 -0
  43. package/dist/constants/dsl/index.js.map +1 -1
  44. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  45. package/dist/dsl/engine-client/event-mapping.js +14 -0
  46. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  47. package/dist/dsl/events/build-closed-event.d.ts +16 -1
  48. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  49. package/dist/dsl/events/build-closed-event.js +6 -0
  50. package/dist/dsl/events/build-closed-event.js.map +1 -1
  51. package/dist/dsl/events/handlers.d.ts +7 -0
  52. package/dist/dsl/events/handlers.d.ts.map +1 -1
  53. package/dist/dsl/events/handlers.js +37 -7
  54. package/dist/dsl/events/handlers.js.map +1 -1
  55. package/dist/dsl/monitor/create-monitor.d.ts +6 -2
  56. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  57. package/dist/dsl/monitor/create-monitor.js +15 -9
  58. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  59. package/dist/dsl/monitor/dsl-failure-events.d.ts +2 -0
  60. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  61. package/dist/dsl/monitor/dsl-failure-events.js +2 -1
  62. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  63. package/dist/dsl/monitor/process-one-position.d.ts +1 -0
  64. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  65. package/dist/dsl/monitor/process-one-position.js +72 -29
  66. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  67. package/dist/dsl/plugin/index.d.ts +8 -2
  68. package/dist/dsl/plugin/index.d.ts.map +1 -1
  69. package/dist/dsl/plugin/index.js +8 -6
  70. package/dist/dsl/plugin/index.js.map +1 -1
  71. package/dist/health/types.d.ts +13 -0
  72. package/dist/health/types.d.ts.map +1 -1
  73. package/dist/health/types.js +12 -0
  74. package/dist/health/types.js.map +1 -1
  75. package/dist/index.d.ts +16 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +230 -39
  78. package/dist/index.js.map +1 -1
  79. package/dist/runtime/action-registry.d.ts.map +1 -1
  80. package/dist/runtime/action-registry.js +4 -3
  81. package/dist/runtime/action-registry.js.map +1 -1
  82. package/dist/runtime/domain-events.d.ts.map +1 -1
  83. package/dist/runtime/domain-events.js +7 -0
  84. package/dist/runtime/domain-events.js.map +1 -1
  85. package/dist/runtime/lifecycle-events.d.ts +9 -1
  86. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  87. package/dist/runtime/lifecycle-events.js +11 -2
  88. package/dist/runtime/lifecycle-events.js.map +1 -1
  89. package/dist/runtime/reconcile-run.d.ts +13 -3
  90. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  91. package/dist/runtime/reconcile-run.js +4 -3
  92. package/dist/runtime/reconcile-run.js.map +1 -1
  93. package/dist/runtime/reconcile-state.d.ts +8 -0
  94. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  95. package/dist/runtime/reconcile-state.js +4 -1
  96. package/dist/runtime/reconcile-state.js.map +1 -1
  97. package/dist/runtime/run.d.ts +1 -0
  98. package/dist/runtime/run.d.ts.map +1 -1
  99. package/dist/runtime/run.js.map +1 -1
  100. package/dist/runtime/runtime-schema.js +3 -3
  101. package/dist/runtime/runtime-schema.js.map +1 -1
  102. package/dist/runtime/runtime.d.ts +1 -0
  103. package/dist/runtime/runtime.d.ts.map +1 -1
  104. package/dist/runtime/runtime.js +27 -3
  105. package/dist/runtime/runtime.js.map +1 -1
  106. package/dist/runtime/supervisor/long-running-supervisor.d.ts +13 -0
  107. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  108. package/dist/runtime/supervisor/long-running-supervisor.js +1 -0
  109. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  110. package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
  111. package/dist/runtime/validate-dsl-position-tracking.js +2 -1
  112. package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
  113. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  114. package/dist/runtime-api/handlers/ingest-signals.js +1 -0
  115. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  116. package/dist/runtime-api/handlers/types.d.ts +5 -0
  117. package/dist/runtime-api/handlers/types.d.ts.map +1 -1
  118. package/dist/runtime-api/intake/ingest-request.d.ts +17 -0
  119. package/dist/runtime-api/intake/ingest-request.d.ts.map +1 -0
  120. package/dist/runtime-api/intake/ingest-request.js +34 -0
  121. package/dist/runtime-api/intake/ingest-request.js.map +1 -0
  122. package/dist/runtime-api/intake/intake-events.d.ts +1 -1
  123. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  124. package/dist/runtime-api/intake/intake-events.js +9 -3
  125. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  126. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  127. package/dist/runtime-api/intake/intake-mount.js +4 -19
  128. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  129. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
  130. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  131. package/dist/runtime-api/intake/scanner-launcher.js +15 -0
  132. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  133. package/dist/runtime-api/intake/types.d.ts +6 -0
  134. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  135. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  136. package/dist/runtime-api/routes/signals.js +2 -1
  137. package/dist/runtime-api/routes/signals.js.map +1 -1
  138. package/dist/runtime-api/routes/signals.schema.d.ts +1 -0
  139. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  140. package/dist/runtime-api/routes/signals.schema.js +6 -0
  141. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  142. package/dist/scanners/engine/engine.d.ts +22 -1
  143. package/dist/scanners/engine/engine.d.ts.map +1 -1
  144. package/dist/scanners/engine/engine.js +78 -11
  145. package/dist/scanners/engine/engine.js.map +1 -1
  146. package/dist/scanners/engine/lifecycle.d.ts +7 -1
  147. package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
  148. package/dist/scanners/events.d.ts +4 -0
  149. package/dist/scanners/events.d.ts.map +1 -1
  150. package/dist/scanners/events.js +2 -0
  151. package/dist/scanners/events.js.map +1 -1
  152. package/dist/scanners/external/scaffold/delivery.py +136 -14
  153. package/dist/scanners/external/scaffold/scaffold.py +61 -8
  154. package/dist/scanners/external/scaffold/ulid.py +58 -0
  155. package/dist/scanners/external-scanner-receiver.d.ts +17 -0
  156. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  157. package/dist/scanners/external-scanner-receiver.js +8 -0
  158. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  159. package/dist/scanners/runtime-module.d.ts +1 -0
  160. package/dist/scanners/runtime-module.d.ts.map +1 -1
  161. package/dist/scanners/runtime-module.js +59 -9
  162. package/dist/scanners/runtime-module.js.map +1 -1
  163. package/dist/scripts/run-dsl-standalone-mock.js +6 -2
  164. package/dist/scripts/run-dsl-standalone-mock.js.map +1 -1
  165. package/dist/senpi/client.d.ts +16 -3
  166. package/dist/senpi/client.d.ts.map +1 -1
  167. package/dist/senpi/client.js +44 -13
  168. package/dist/senpi/client.js.map +1 -1
  169. package/dist/strategy/strategy-state.d.ts +9 -1
  170. package/dist/strategy/strategy-state.d.ts.map +1 -1
  171. package/dist/strategy/strategy-state.js +7 -2
  172. package/dist/strategy/strategy-state.js.map +1 -1
  173. package/dist/telemetry/attributes.d.ts +20 -0
  174. package/dist/telemetry/attributes.d.ts.map +1 -1
  175. package/dist/telemetry/attributes.js +24 -0
  176. package/dist/telemetry/attributes.js.map +1 -1
  177. package/dist/telemetry/event-builders.d.ts +81 -6
  178. package/dist/telemetry/event-builders.d.ts.map +1 -1
  179. package/dist/telemetry/event-builders.js +141 -17
  180. package/dist/telemetry/event-builders.js.map +1 -1
  181. package/dist/telemetry/event-store.d.ts +6 -0
  182. package/dist/telemetry/event-store.d.ts.map +1 -1
  183. package/dist/telemetry/event-store.js +12 -8
  184. package/dist/telemetry/event-store.js.map +1 -1
  185. package/dist/telemetry/live-telemetry.d.ts +192 -0
  186. package/dist/telemetry/live-telemetry.d.ts.map +1 -0
  187. package/dist/telemetry/live-telemetry.js +676 -0
  188. package/dist/telemetry/live-telemetry.js.map +1 -0
  189. package/dist/telemetry/metrics.d.ts +14 -2
  190. package/dist/telemetry/metrics.d.ts.map +1 -1
  191. package/dist/telemetry/metrics.js +19 -4
  192. package/dist/telemetry/metrics.js.map +1 -1
  193. package/dist/telemetry/scanner-stats.d.ts +143 -0
  194. package/dist/telemetry/scanner-stats.d.ts.map +1 -0
  195. package/dist/telemetry/scanner-stats.js +426 -0
  196. package/dist/telemetry/scanner-stats.js.map +1 -0
  197. package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
  198. package/dist/telemetry/spans/redact-pii.js +18 -2
  199. package/dist/telemetry/spans/redact-pii.js.map +1 -1
  200. package/dist/telemetry/strategy-topology.d.ts +99 -0
  201. package/dist/telemetry/strategy-topology.d.ts.map +1 -0
  202. package/dist/telemetry/strategy-topology.js +93 -0
  203. package/dist/telemetry/strategy-topology.js.map +1 -0
  204. package/dist/telemetry/telemetry-publisher.d.ts +63 -0
  205. package/dist/telemetry/telemetry-publisher.d.ts.map +1 -0
  206. package/dist/telemetry/telemetry-publisher.js +229 -0
  207. package/dist/telemetry/telemetry-publisher.js.map +1 -0
  208. package/dist/telemetry/timeline-buffer.d.ts +95 -0
  209. package/dist/telemetry/timeline-buffer.d.ts.map +1 -0
  210. package/dist/telemetry/timeline-buffer.js +237 -0
  211. package/dist/telemetry/timeline-buffer.js.map +1 -0
  212. package/dist/telemetry/tracing.d.ts +9 -0
  213. package/dist/telemetry/tracing.d.ts.map +1 -1
  214. package/dist/telemetry/tracing.js +36 -0
  215. package/dist/telemetry/tracing.js.map +1 -1
  216. package/dist/types/dsl/index.d.ts +11 -0
  217. package/dist/types/dsl/index.d.ts.map +1 -1
  218. package/dist/types/scanner.d.ts +15 -0
  219. package/dist/types/scanner.d.ts.map +1 -1
  220. package/dist/types/strategy.d.ts +5 -1
  221. package/dist/types/strategy.d.ts.map +1 -1
  222. package/dist/utils/event-catalog.d.ts +166 -0
  223. package/dist/utils/event-catalog.d.ts.map +1 -1
  224. package/dist/utils/event-catalog.js +18 -0
  225. package/dist/utils/event-catalog.js.map +1 -1
  226. package/dist/utils/logger.d.ts +4 -1
  227. package/dist/utils/logger.d.ts.map +1 -1
  228. package/dist/utils/logger.js.map +1 -1
  229. package/dist/utils/telemetry-required-log.d.ts +27 -0
  230. package/dist/utils/telemetry-required-log.d.ts.map +1 -0
  231. package/dist/utils/telemetry-required-log.js +68 -0
  232. package/dist/utils/telemetry-required-log.js.map +1 -0
  233. package/dist/utils/ulid.d.ts +9 -0
  234. package/dist/utils/ulid.d.ts.map +1 -0
  235. package/dist/utils/ulid.js +43 -0
  236. package/dist/utils/ulid.js.map +1 -0
  237. package/dist/validate/recipe-checks.d.ts.map +1 -1
  238. package/dist/validate/recipe-checks.js +2 -2
  239. package/dist/validate/recipe-checks.js.map +1 -1
  240. package/openclaw.plugin.json +1 -1
  241. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -62,7 +62,9 @@ import { createIntakeContext } from "./runtime-api/intake/intake-context.js";
62
62
  import { wireRuntime, intakeUrlFor, selectScanners } from "./runtime-api/intake/intake-mount.js";
63
63
  import { ingestDispatchMutex, ingestDispatchKey } from "./runtime-api/intake/ingest-mutex.js";
64
64
  import { buildScaffoldErrorRouter } from "./runtime-api/intake/scaffold-error-router.js";
65
+ import { toIngestRequest } from "./runtime-api/intake/ingest-request.js";
65
66
  import { loadRuntimeConfig } from "./runtime/load-runtime-config.js";
67
+ import { createLiveTelemetry } from "./telemetry/live-telemetry.js";
66
68
  /** Initialize all core features (DSL, risk, health) on the given event bus. */
67
69
  export function initCore(bus) {
68
70
  initDsl(bus);
@@ -376,10 +378,146 @@ const senpiRuntimePlugin = {
376
378
  * engines, one margin account: the exact state `[E_WALLET_HAS_RUNTIME]` exists to prevent.
377
379
  */
378
380
  const installsInFlight = new Map();
381
+ // ── Live telemetry ────────────────────────────────────────────────
382
+ // Every runtime this box knows about, running or not, keyed runtimeId. The topology payload has
383
+ // to name a stopped strategy too, so this is populated from the registry rather than from
384
+ // `runtimeHandles` (which holds only the live ones) and is the source of the two facts the
385
+ // registry entry does not carry: the strategy's display name and its declared scanners. Both come
386
+ // from the parsed YAML — `name` is the same `config.name` that `RuntimeHealthStatus.runtimeName`
387
+ // is built from, so a runtime whose config never parsed falls back to its id rather than to a
388
+ // guess, and its scanner list stays empty rather than inventing rows.
389
+ const telemetryStrategies = new Map();
390
+ const noteTelemetryRuntime = (runtimeId, address) => {
391
+ const existing = telemetryStrategies.get(runtimeId);
392
+ telemetryStrategies.set(runtimeId, {
393
+ address,
394
+ name: existing?.name ?? runtimeId,
395
+ scanners: existing?.scanners ?? [],
396
+ });
397
+ };
398
+ const noteTelemetryRuntimeConfig = (runtimeId, config) => {
399
+ telemetryStrategies.set(runtimeId, {
400
+ address: config.strategy?.wallet ?? telemetryStrategies.get(runtimeId)?.address ?? "",
401
+ name: config.name,
402
+ scanners: config.scanners ?? [],
403
+ });
404
+ };
379
405
  // Dedicated logger for the runtime-api subtree — required because the
380
406
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
381
407
  // Logger contract used by `runtime-api/` includes it.
382
408
  const apiLogger = createLogger("[senpi-core][api]");
409
+ /**
410
+ * One gather per publish tick. Health and system state are read together because the two payloads
411
+ * need both: the topology reads health and `runtimeName`, the stats read the per-scanner rows,
412
+ * and `restartCount` / `degradedReason` live only on the health rows while every other scanner
413
+ * field lives only on the system-state rows — so they are joined here on `(runtimeId, scannerId)`.
414
+ */
415
+ const collectTelemetryFacts = async () => {
416
+ const [statuses, states] = await Promise.all([
417
+ collectRuntimeHealthStatuses(runtimeHandles, api.logger),
418
+ collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir),
419
+ ]);
420
+ const healthByRuntime = new Map();
421
+ for (const status of statuses) {
422
+ if (status.runtimeId !== undefined)
423
+ healthByRuntime.set(status.runtimeId, status);
424
+ }
425
+ const scannerState = [];
426
+ // Registration facts per (runtimeId, scannerId), so the topology can report a scanner's live
427
+ // schedule rather than only what its YAML declared.
428
+ const registrations = new Map();
429
+ for (const state of states) {
430
+ const runtimeId = state.runtimeId;
431
+ const healthRows = runtimeId === undefined
432
+ ? []
433
+ : healthByRuntime.get(runtimeId)?.components.scanners.scanners ?? [];
434
+ for (const row of state.components.scanners.state.scanners) {
435
+ const supervision = healthRows.find((r) => r.scannerId === row.scannerId);
436
+ const snapshot = {
437
+ address: row.address,
438
+ scannerId: row.scannerId,
439
+ health: row.health,
440
+ degradedReason: supervision?.degradedReason ?? null,
441
+ enabled: row.enabled,
442
+ inFlightSince: row.inFlightSince,
443
+ lastRunStartedAt: row.lastRunStartedAt,
444
+ lastRunFinishedAt: row.lastRunFinishedAt,
445
+ lastRunStatus: row.lastRunStatus,
446
+ lastRunReason: row.lastRunReason,
447
+ nextRunAt: row.nextRunAt,
448
+ lastAliveAt: row.lastAliveAt,
449
+ // No supervision row means the restart count is unknown, not zero. A zero here would
450
+ // read as "this scanner has never restarted" on a scanner nobody is supervising.
451
+ restartCount: supervision?.restartCount ?? null,
452
+ };
453
+ scannerState.push(snapshot);
454
+ if (runtimeId !== undefined) {
455
+ registrations.set(`${runtimeId}::${row.scannerId}`, {
456
+ ...snapshot,
457
+ scheduleMode: row.scheduleMode,
458
+ configVersion: row.configVersion,
459
+ intervalSeconds: row.intervalSeconds,
460
+ });
461
+ }
462
+ }
463
+ }
464
+ const strategies = [];
465
+ for (const [runtimeId, entry] of telemetryStrategies) {
466
+ const health = healthByRuntime.get(runtimeId);
467
+ const scanners = entry.scanners.map((declared) => {
468
+ const live = registrations.get(`${runtimeId}::${declared.name}`);
469
+ return live === undefined
470
+ ? { entry: declared }
471
+ : {
472
+ entry: declared,
473
+ registration: {
474
+ enabled: live.enabled,
475
+ scheduleMode: live.scheduleMode,
476
+ // The engine's own cadence, which is what the scanner actually polls on — a
477
+ // recipe may omit it and take the scanner definition's default. An external
478
+ // scanner registers zero because it is POSTed to rather than polled, and only
479
+ // the recipe knows the cadence its scaffold was told to tick at.
480
+ intervalSeconds: live.intervalSeconds > 0 ? live.intervalSeconds : declared.interval_seconds ?? null,
481
+ configVersion: live.configVersion,
482
+ },
483
+ };
484
+ });
485
+ strategies.push({
486
+ runtimeId,
487
+ address: entry.address,
488
+ // `runtimeName` is built from the same `config.name`; the indexed value is used because it
489
+ // is available for a stopped runtime too, where there is no health status to read.
490
+ name: health?.runtimeName ?? entry.name,
491
+ scanners,
492
+ });
493
+ }
494
+ return {
495
+ strategies,
496
+ runningRuntimeIds: runtimeHandles,
497
+ unwiredRuntimeIds: unwiredRuntimes,
498
+ readHealth: (runtimeId) => healthByRuntime.get(runtimeId),
499
+ scannerState,
500
+ };
501
+ };
502
+ const liveTelemetry = createLiveTelemetry({ collectFacts: collectTelemetryFacts });
503
+ /**
504
+ * Wrapped gateway methods capture the broadcast handle on the FIRST request to ANY of them —
505
+ * not only on subscribe. `ctx.context` is the only route to the websocket push layer and it
506
+ * exists only inside a handler, so capturing it in the subscribe method alone would mean a box
507
+ * whose client never subscribes can never publish anything, silently.
508
+ *
509
+ * The wrapper covers only what is registered through it. `senpi.deploy.start`,
510
+ * `senpi.deploy.status` and `senpi.update` are registered straight onto the plugin api by their
511
+ * own registrars and never arm the handle. That is not a gap in practice: one wrapped call arms
512
+ * it for the life of the process, and the websocket client admits `senpi.telemetry.subscribe`,
513
+ * which is wrapped.
514
+ */
515
+ const registerGatewayMethod = (name, handler, opts) => {
516
+ api.registerGatewayMethod(name, (ctx) => {
517
+ liveTelemetry.captureBroadcast(ctx, name);
518
+ return handler(ctx);
519
+ }, opts);
520
+ };
383
521
  // A gateway seam failed to wire/launch a runtime's external scanner stack: the runtime is up but
384
522
  // runs blind on internal scanners only. Two records, by design: a first-class `runtime.scanners_unwired`
385
523
  // event (the queryable/alertable fact, by phase) and the migrated exception-form `logger.error`
@@ -520,26 +658,14 @@ const senpiRuntimePlugin = {
520
658
  // /signals will not be mounted and no signals will be routed.
521
659
  if (sharedIntakeContext) {
522
660
  const ctx = sharedIntakeContext;
523
- routeAcceptedSignalToRuntime = (signal, _correlationId, scannerId) => {
661
+ routeAcceptedSignalToRuntime = (signal, correlationId, scannerId) => {
524
662
  const resolved = ctx.registry.resolve(scannerId);
525
663
  if (!resolved)
526
664
  return;
527
665
  const h = runtimeHandles.get(resolved.runtimeId);
528
666
  if (!h)
529
667
  return;
530
- const request = {
531
- scanner: resolved.scannerName,
532
- signal_id: signal.signal_id, // greppable handle across intake→ingest (obs only)
533
- asset: signal.asset,
534
- direction: signal.direction,
535
- ...(signal.signal_type !== undefined && { signal_type: signal.signal_type }),
536
- data: signal.data,
537
- // Per-signal sizing rides at the top level; the open-position action
538
- // reads request.marginPct / request.leverage directly. Without this a
539
- // supervised signal's sizing is lost ("no margin source resolved").
540
- ...(signal.marginPct !== undefined && { marginPct: signal.marginPct }),
541
- ...(signal.leverage !== undefined && { leverage: signal.leverage }),
542
- };
668
+ const request = toIngestRequest(signal, correlationId, resolved.scannerName);
543
669
  // Trace the sizing-relevant fields the narrowed request carries, so a
544
670
  // field dropped at this seam is visible instead of vanishing silently.
545
671
  apiLogger.debug(`[intake] ingest req signal_id=${signal.signal_id} scanner=${resolved.scannerName} asset=${signal.asset} direction=${signal.direction} marginPct=${signal.marginPct} leverage=${signal.leverage}`);
@@ -559,14 +685,13 @@ const senpiRuntimePlugin = {
559
685
  // The single server-level onError closure — the `/errors` counterpart of
560
686
  // routeAcceptedSignalToRuntime. It records a scaffold-reported tick failure into
561
687
  // the owning runtime's scanner telemetry so a scanner that fails every tick stops
562
- // rendering "healthy".
688
+ // rendering "healthy". Filter: only tick-level failure types count — a
689
+ // `candidate_rejected` body is a per-candidate reject on an otherwise-OK tick, not
690
+ // a failed run.
563
691
  //
564
- // This is the SHARED implementation (scaffold-error-router.ts), not a copy of
565
- // buildIntakeMount's onScaffoldError. It used to be a copy, and the copy had its
566
- // own hardcoded three-type filter: when `mcp_error` was added to the tick-failure
567
- // list it reached the tested seam and never reached this route, so the
568
- // wedged-scanner signal was green in CI and inert in production. There is now one
569
- // filter, and scaffold-error-router.test.ts drives THIS closure.
692
+ // This is the SHARED implementation (scaffold-error-router.ts), so this route and
693
+ // buildIntakeMount's onScaffoldError apply one filter, and scaffold-error-router.test.ts
694
+ // drives THIS closure.
570
695
  routeScaffoldErrorToRuntime = buildScaffoldErrorRouter(ctx, runtimeHandles);
571
696
  }
572
697
  const started = [];
@@ -580,6 +705,9 @@ const senpiRuntimePlugin = {
580
705
  continue;
581
706
  const pathOrContent = entry.runtimeYamlPath ?? entry.runtimeYamlContent;
582
707
  const entryStateDir = buildRuntimeStateDir(baseStateDir, entry.id, entry.wallet ?? "");
708
+ // Indexed before the boot attempt: a runtime whose start throws is installed but stopped,
709
+ // and the telemetry topology has to be able to say so.
710
+ noteTelemetryRuntime(entry.id, entry.wallet ?? "");
583
711
  const handle = await run(pathOrContent, {
584
712
  fromPath: hasPath,
585
713
  stateDir: entryStateDir,
@@ -602,6 +730,7 @@ const senpiRuntimePlugin = {
602
730
  if (sharedIntakeContext) {
603
731
  try {
604
732
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
733
+ noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
605
734
  // Content-installed runtimes carry their recipe dir in the registry
606
735
  // entry (runtimeYamlDir) so relative scanner paths still resolve
607
736
  // after a restart; path-installed ones derive it from the path.
@@ -653,6 +782,7 @@ const senpiRuntimePlugin = {
653
782
  // event) so listRuntimes/health don't render a bare "running".
654
783
  try {
655
784
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
785
+ noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
656
786
  if (selectScanners(runtimeConfig).length > 0) {
657
787
  unwiredRuntimes.set(entry.id, "no_intake");
658
788
  handle.attachSupervisionSource(() => ({
@@ -754,8 +884,19 @@ const senpiRuntimePlugin = {
754
884
  }
755
885
  }
756
886
  }
887
+ // Publish ticks start only once the runtimes are up, so the first frames describe a real
888
+ // box. Gated by the same switch the skills coordinator uses: unit tests boot `register()`
889
+ // and must not acquire background timers. The subscribe method still answers without them.
890
+ if (process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
891
+ liveTelemetry.start();
892
+ }
757
893
  },
758
894
  async stop() {
895
+ // Live telemetry goes first: it owns a timer and an in-memory buffer, nothing downstream
896
+ // depends on it, and stopping it here guarantees no publish tick reads a half-torn-down
897
+ // runtime handle. Records emitted during the teardown below land in a buffer nobody drains,
898
+ // which is the right trade for a process on its way out.
899
+ liveTelemetry.stop();
759
900
  // Drain the HTTP server FIRST so in-flight POST /signals requests
760
901
  // finish before the underlying runtime they target is torn down.
761
902
  // `apiHandle.close()` never throws.
@@ -1090,6 +1231,10 @@ const senpiRuntimePlugin = {
1090
1231
  runtimeId: resolvedId,
1091
1232
  });
1092
1233
  runtimeHandles.set(resolvedId, handle);
1234
+ if (parsedConfig)
1235
+ noteTelemetryRuntimeConfig(resolvedId, parsedConfig);
1236
+ else
1237
+ noteTelemetryRuntime(resolvedId, wallet);
1093
1238
  // Registry create fact — distinct from the runtime.started boot transition (which also fires
1094
1239
  // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
1095
1240
  emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
@@ -1202,7 +1347,7 @@ const senpiRuntimePlugin = {
1202
1347
  }
1203
1348
  }
1204
1349
  /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
1205
- api.registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1350
+ registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1206
1351
  const respond = ctx.respond;
1207
1352
  const result = await installRuntimeCore(ctx.params);
1208
1353
  if (result.ok)
@@ -1210,7 +1355,7 @@ const senpiRuntimePlugin = {
1210
1355
  else
1211
1356
  respond(false, undefined, result.error);
1212
1357
  }, { scope: "operator.write" });
1213
- api.registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1358
+ registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1214
1359
  const respond = ctx.respond;
1215
1360
  const { readRegistry } = await import("./runtime/strategy-registry.js");
1216
1361
  const registry = await readRegistry(baseStateDir);
@@ -1293,6 +1438,9 @@ const senpiRuntimePlugin = {
1293
1438
  // "running — NO ENTRY SCANNERS" forever. The map is otherwise only cleared on a
1294
1439
  // successful wire/install, which a scanner-less reinstall never reaches.
1295
1440
  unwiredRuntimes.delete(targetId);
1441
+ // Same reuse hazard: a deleted id left in the index would keep publishing a strategy that no
1442
+ // longer exists, and would hand a reinstall the old name and scanner list.
1443
+ telemetryStrategies.delete(targetId);
1296
1444
  // Archive state directory before removing from registry
1297
1445
  if (deletedEntry) {
1298
1446
  const runtimeStateDir = buildRuntimeStateDir(baseStateDir, targetId, deletedEntry.wallet ?? "");
@@ -1321,7 +1469,7 @@ const senpiRuntimePlugin = {
1321
1469
  }));
1322
1470
  return { ok: true, payload: { id: targetId } };
1323
1471
  }
1324
- api.registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1472
+ registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1325
1473
  const respond = ctx.respond;
1326
1474
  const result = await deleteRuntimeCore(ctx.params);
1327
1475
  if (result.ok)
@@ -1428,6 +1576,17 @@ const senpiRuntimePlugin = {
1428
1576
  return checkProofCoversRecipe(packageDir, verifyProof(packageDir), appliedRecipe, (f) => readFileSync(f, "utf8"));
1429
1577
  },
1430
1578
  applyToRuntime: makeApplyToRuntime(runtimeHandles, async (runtimeId, next, recipe) => {
1579
+ // Re-index the topology facts FIRST, ahead of every early return and every failure path
1580
+ // below. `makeApplyToRuntime` only reaches this callback once the in-process swap has
1581
+ // succeeded, so `next` is already the recipe this box runs: its display name and its
1582
+ // declared scanner list are the ones the topology payload must name. Leaving the index on
1583
+ // the previous config would publish strategy rows the new scanner stats cannot join to.
1584
+ //
1585
+ // Ahead of the early return because an apply on a box with no intake still swapped the
1586
+ // recipe, and ahead of the launcher because a launcher that fails is a runtime running the
1587
+ // new recipe with no external scanners — the degraded state a dashboard most needs to show,
1588
+ // and one it can only render if the declared scanners are the new ones.
1589
+ noteTelemetryRuntimeConfig(runtimeId, next);
1431
1590
  // Restart this runtime's external scanner children against the new package.
1432
1591
  //
1433
1592
  // The rebuild swaps in-process components; these are separate OS processes, spawned once
@@ -1575,7 +1734,7 @@ const senpiRuntimePlugin = {
1575
1734
  * producers — slated for removal in Task 5 once the migration counter
1576
1735
  * decays to zero (see `runtime-phase-2-api-plan.md` Section 8).
1577
1736
  */
1578
- api.registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1737
+ registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1579
1738
  const respond = ctx.respond;
1580
1739
  // TODO(s11): wire actual telemetry counter for
1581
1740
  // runtime.deprecated_gateway_ingest_call (event name, properties:
@@ -1594,6 +1753,7 @@ const senpiRuntimePlugin = {
1594
1753
  ...(request.direction !== undefined && { direction: request.direction }),
1595
1754
  ...(request.score !== undefined && { score: request.score }),
1596
1755
  ...(request.signal_type !== undefined && { signal_type: request.signal_type }),
1756
+ ...(request.tick_id !== undefined && { tick_id: request.tick_id }),
1597
1757
  ...(request.data !== undefined && { data: request.data }),
1598
1758
  ...(request.signals !== undefined && { signals: request.signals }),
1599
1759
  };
@@ -1651,7 +1811,7 @@ const senpiRuntimePlugin = {
1651
1811
  });
1652
1812
  }
1653
1813
  }, { scope: "operator.write" });
1654
- api.registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1814
+ registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1655
1815
  const respond = ctx.respond;
1656
1816
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1657
1817
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1676,7 +1836,7 @@ const senpiRuntimePlugin = {
1676
1836
  }
1677
1837
  respond(true, { positions });
1678
1838
  }, { scope: "operator.read" });
1679
- api.registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1839
+ registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1680
1840
  const respond = ctx.respond;
1681
1841
  const asset = typeof ctx.params?.asset === "string" ? ctx.params.asset : undefined;
1682
1842
  if (!asset) {
@@ -1717,7 +1877,7 @@ const senpiRuntimePlugin = {
1717
1877
  }
1718
1878
  respond(true, { positions: matches });
1719
1879
  }, { scope: "operator.read" });
1720
- api.registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1880
+ registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1721
1881
  const respond = ctx.respond;
1722
1882
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1723
1883
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1743,7 +1903,7 @@ const senpiRuntimePlugin = {
1743
1903
  closes = closes.slice(0, limit);
1744
1904
  respond(true, { closes });
1745
1905
  }, { scope: "operator.read" });
1746
- api.registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1906
+ registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1747
1907
  const respond = ctx.respond;
1748
1908
  const updater = autoUpdateCoordinator.getSnapshot();
1749
1909
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1771,7 +1931,7 @@ const senpiRuntimePlugin = {
1771
1931
  const statuses = await collectRuntimeHealthStatuses(runtimeHandles, api.logger);
1772
1932
  respond(true, { statuses, updater });
1773
1933
  }, { scope: "operator.read" });
1774
- api.registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1934
+ registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1775
1935
  const respond = ctx.respond;
1776
1936
  const updater = autoUpdateCoordinator.getSnapshot();
1777
1937
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1799,6 +1959,36 @@ const senpiRuntimePlugin = {
1799
1959
  const states = await collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir);
1800
1960
  respond(true, { states, updater });
1801
1961
  }, { scope: "operator.read" });
1962
+ /**
1963
+ * Live telemetry — the one method of the stream, called once per connect.
1964
+ *
1965
+ * It returns the current value of all three broadcast payloads plus the recent record window, so
1966
+ * a client joining a box that has been running for hours renders a full screen immediately
1967
+ * instead of waiting for the next thing to happen. `(afterEpoch, afterSeq)` resumes a client that
1968
+ * already holds part of this epoch's run — a cursor naming any other epoch is dropped, because a
1969
+ * seq from a previous run cannot address these records. `truncated` says when the records between
1970
+ * a cursor of this epoch and the oldest record still held are gone, which nothing in `recent`
1971
+ * alone can express.
1972
+ *
1973
+ * Read-only, like every other inspection method here. The scope that decides who RECEIVES the
1974
+ * broadcasts is a property of the client's own connection, not of this method.
1975
+ */
1976
+ registerGatewayMethod("senpi.telemetry.subscribe", async (ctx) => {
1977
+ const respond = ctx.respond;
1978
+ try {
1979
+ respond(true, await liveTelemetry.snapshot({
1980
+ afterEpoch: ctx.params?.afterEpoch,
1981
+ afterSeq: ctx.params?.afterSeq,
1982
+ }));
1983
+ }
1984
+ catch (err) {
1985
+ api.logger.warn("[senpi-runtime] telemetry subscribe error", { err });
1986
+ respond(false, undefined, {
1987
+ code: "INTERNAL_ERROR",
1988
+ message: `Failed to build the telemetry snapshot: ${formatGatewayError(err)}`,
1989
+ });
1990
+ }
1991
+ }, { scope: "operator.read" });
1802
1992
  /**
1803
1993
  * Action layer — read-only inspection.
1804
1994
  *
@@ -1818,7 +2008,7 @@ const senpiRuntimePlugin = {
1818
2008
  * persisted action-store machinery (`src/actions/store/`) usable
1819
2009
  * from the operator-facing CLI again.
1820
2010
  */
1821
- api.registerGatewayMethod("senpi.listActions", async (ctx) => {
2011
+ registerGatewayMethod("senpi.listActions", async (ctx) => {
1822
2012
  const respond = ctx.respond;
1823
2013
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1824
2014
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1848,7 +2038,7 @@ const senpiRuntimePlugin = {
1848
2038
  }
1849
2039
  respond(true, { actions: merged });
1850
2040
  }, { scope: "operator.read" });
1851
- api.registerGatewayMethod("senpi.getActionState", async (ctx) => {
2041
+ registerGatewayMethod("senpi.getActionState", async (ctx) => {
1852
2042
  const respond = ctx.respond;
1853
2043
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1854
2044
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -1885,7 +2075,7 @@ const senpiRuntimePlugin = {
1885
2075
  });
1886
2076
  }
1887
2077
  }, { scope: "operator.read" });
1888
- api.registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
2078
+ registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
1889
2079
  const respond = ctx.respond;
1890
2080
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1891
2081
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -1953,7 +2143,7 @@ const senpiRuntimePlugin = {
1953
2143
  // reachable on HTTP `/audit`. Passes through the caller-controlled subset
1954
2144
  // of the audit_query contract; resource scoping (wallet → strategyId) is
1955
2145
  // owned by handle.auditQuery, never the caller.
1956
- api.registerGatewayMethod("senpi.auditQuery", async (ctx) => {
2146
+ registerGatewayMethod("senpi.auditQuery", async (ctx) => {
1957
2147
  const respond = ctx.respond;
1958
2148
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1959
2149
  if (!runtimeId) {
@@ -2001,7 +2191,7 @@ const senpiRuntimePlugin = {
2001
2191
  // its state root private, so re-derive it from baseStateDir + the handle's
2002
2192
  // wallet (same helper the writer's stateDir was built with); the writer
2003
2193
  // nests entries under `<root>/<address>/risk-guard/audit.jsonl`.
2004
- api.registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
2194
+ registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
2005
2195
  const respond = ctx.respond;
2006
2196
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2007
2197
  if (!runtimeId) {
@@ -2044,7 +2234,7 @@ const senpiRuntimePlugin = {
2044
2234
  // Per-strategy local domain-event ring (on-disk JSONL) — the trade narrative, queryable locally.
2045
2235
  // Same state-root re-derivation as getRiskAudit: the Runtime keeps its root private, so rebuild it
2046
2236
  // from baseStateDir + the handle's wallet; the writer nests rows under `<root>/strategies/<address>/events/`.
2047
- api.registerGatewayMethod("senpi.getEvents", async (ctx) => {
2237
+ registerGatewayMethod("senpi.getEvents", async (ctx) => {
2048
2238
  const respond = ctx.respond;
2049
2239
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2050
2240
  if (!runtimeId) {
@@ -2101,12 +2291,12 @@ const senpiRuntimePlugin = {
2101
2291
  }
2102
2292
  }, { scope: "operator.read" });
2103
2293
  // ─── Skills manager — read + mutate ──────────────────────────────────────
2104
- api.registerGatewayMethod("senpi.skills.status", async (ctx) => {
2294
+ registerGatewayMethod("senpi.skills.status", async (ctx) => {
2105
2295
  const respond = ctx.respond;
2106
2296
  const snapshot = skillsCoordinator.getSnapshot();
2107
2297
  respond(true, { snapshot });
2108
2298
  }, { scope: "operator.read" });
2109
- api.registerGatewayMethod("senpi.skills.update", async (ctx) => {
2299
+ registerGatewayMethod("senpi.skills.update", async (ctx) => {
2110
2300
  const respond = ctx.respond;
2111
2301
  const name = typeof ctx.params?.name === "string" ? ctx.params.name.trim() : undefined;
2112
2302
  if (!name) {
@@ -2131,7 +2321,7 @@ const senpiRuntimePlugin = {
2131
2321
  });
2132
2322
  }
2133
2323
  }, { scope: "operator.write" });
2134
- api.registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
2324
+ registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
2135
2325
  const respond = ctx.respond;
2136
2326
  try {
2137
2327
  const results = await skillsCoordinator.updateAll();
@@ -2197,6 +2387,7 @@ function parseGatewayExternalScannerIngestParams(params) {
2197
2387
  direction: params?.direction,
2198
2388
  score: params?.score,
2199
2389
  signal_type: params?.signal_type,
2390
+ tick_id: params?.tick_id,
2200
2391
  data: params?.data,
2201
2392
  signals: params?.signals,
2202
2393
  },