@adonis-agora/durable 0.9.1 → 0.11.0

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 (164) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/dashboard.html +116 -3
  3. package/dist/commands/durable_worker.d.ts +25 -0
  4. package/dist/commands/durable_worker.d.ts.map +1 -0
  5. package/dist/commands/durable_worker.js +113 -0
  6. package/dist/commands/durable_worker.js.map +1 -0
  7. package/dist/commands/main.d.ts +6 -5
  8. package/dist/commands/main.d.ts.map +1 -1
  9. package/dist/commands/main.js +11 -4
  10. package/dist/commands/main.js.map +1 -1
  11. package/dist/providers/dashboard_provider.d.ts +38 -0
  12. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  13. package/dist/providers/dashboard_provider.js +103 -10
  14. package/dist/providers/dashboard_provider.js.map +1 -1
  15. package/dist/providers/durable_provider.d.ts +24 -16
  16. package/dist/providers/durable_provider.d.ts.map +1 -1
  17. package/dist/providers/durable_provider.js +195 -20
  18. package/dist/providers/durable_provider.js.map +1 -1
  19. package/dist/src/config_types.d.ts +92 -0
  20. package/dist/src/config_types.d.ts.map +1 -0
  21. package/dist/src/config_types.js +2 -0
  22. package/dist/src/config_types.js.map +1 -0
  23. package/dist/src/dashboard/compat-view.d.ts +13 -0
  24. package/dist/src/dashboard/compat-view.d.ts.map +1 -0
  25. package/dist/src/dashboard/compat-view.js +32 -0
  26. package/dist/src/dashboard/compat-view.js.map +1 -0
  27. package/dist/src/dashboard/compat.d.ts +76 -0
  28. package/dist/src/dashboard/compat.d.ts.map +1 -0
  29. package/dist/src/dashboard/compat.js +130 -0
  30. package/dist/src/dashboard/compat.js.map +1 -0
  31. package/dist/src/dashboard/diagnostics-recorder.d.ts +59 -0
  32. package/dist/src/dashboard/diagnostics-recorder.d.ts.map +1 -0
  33. package/dist/src/dashboard/diagnostics-recorder.js +75 -0
  34. package/dist/src/dashboard/diagnostics-recorder.js.map +1 -0
  35. package/dist/src/dashboard/gateway-adapter.d.ts +37 -0
  36. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -0
  37. package/dist/src/dashboard/gateway-adapter.js +49 -0
  38. package/dist/src/dashboard/gateway-adapter.js.map +1 -0
  39. package/dist/src/dashboard/handlers.d.ts +38 -8
  40. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  41. package/dist/src/dashboard/handlers.js +17 -1
  42. package/dist/src/dashboard/handlers.js.map +1 -1
  43. package/dist/src/dashboard/index.d.ts +10 -3
  44. package/dist/src/dashboard/index.d.ts.map +1 -1
  45. package/dist/src/dashboard/index.js +8 -2
  46. package/dist/src/dashboard/index.js.map +1 -1
  47. package/dist/src/define_config.d.ts +31 -5
  48. package/dist/src/define_config.d.ts.map +1 -1
  49. package/dist/src/define_config.js +4 -3
  50. package/dist/src/define_config.js.map +1 -1
  51. package/dist/src/dispatch-routing.d.ts +105 -0
  52. package/dist/src/dispatch-routing.d.ts.map +1 -0
  53. package/dist/src/dispatch-routing.js +114 -0
  54. package/dist/src/dispatch-routing.js.map +1 -0
  55. package/dist/src/engine.d.ts +129 -3
  56. package/dist/src/engine.d.ts.map +1 -1
  57. package/dist/src/engine.js +354 -12
  58. package/dist/src/engine.js.map +1 -1
  59. package/dist/src/handshake/descriptor.d.ts +128 -0
  60. package/dist/src/handshake/descriptor.d.ts.map +1 -0
  61. package/dist/src/handshake/descriptor.js +133 -0
  62. package/dist/src/handshake/descriptor.js.map +1 -0
  63. package/dist/src/handshake/negotiate.d.ts +83 -0
  64. package/dist/src/handshake/negotiate.d.ts.map +1 -0
  65. package/dist/src/handshake/negotiate.js +108 -0
  66. package/dist/src/handshake/negotiate.js.map +1 -0
  67. package/dist/src/handshake/routing.d.ts +51 -0
  68. package/dist/src/handshake/routing.d.ts.map +1 -0
  69. package/dist/src/handshake/routing.js +57 -0
  70. package/dist/src/handshake/routing.js.map +1 -0
  71. package/dist/src/index.d.ts +14 -2
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +12 -1
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/interfaces.d.ts +184 -7
  76. package/dist/src/interfaces.d.ts.map +1 -1
  77. package/dist/src/remote-workflow-executor.d.ts +25 -1
  78. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  79. package/dist/src/remote-workflow-executor.js +41 -0
  80. package/dist/src/remote-workflow-executor.js.map +1 -1
  81. package/dist/src/role_bindings.d.ts +85 -0
  82. package/dist/src/role_bindings.d.ts.map +1 -0
  83. package/dist/src/role_bindings.js +73 -0
  84. package/dist/src/role_bindings.js.map +1 -0
  85. package/dist/src/run-gateway/interface.d.ts +89 -0
  86. package/dist/src/run-gateway/interface.d.ts.map +1 -0
  87. package/dist/src/run-gateway/interface.js +2 -0
  88. package/dist/src/run-gateway/interface.js.map +1 -0
  89. package/dist/src/run-gateway/proxy-run-gateway.d.ts +84 -0
  90. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -0
  91. package/dist/src/run-gateway/proxy-run-gateway.js +142 -0
  92. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -0
  93. package/dist/src/run-gateway/run-request-responder.d.ts +67 -0
  94. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -0
  95. package/dist/src/run-gateway/run-request-responder.js +228 -0
  96. package/dist/src/run-gateway/run-request-responder.js.map +1 -0
  97. package/dist/src/run-gateway/store-run-gateway.d.ts +90 -0
  98. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -0
  99. package/dist/src/run-gateway/store-run-gateway.js +80 -0
  100. package/dist/src/run-gateway/store-run-gateway.js.map +1 -0
  101. package/dist/src/run-gateway/tenant-auth.d.ts +19 -0
  102. package/dist/src/run-gateway/tenant-auth.d.ts.map +1 -0
  103. package/dist/src/run-gateway/tenant-auth.js +71 -0
  104. package/dist/src/run-gateway/tenant-auth.js.map +1 -0
  105. package/dist/src/services/main.d.ts +16 -3
  106. package/dist/src/services/main.d.ts.map +1 -1
  107. package/dist/src/services/main.js +23 -4
  108. package/dist/src/services/main.js.map +1 -1
  109. package/dist/src/step-name-symbol.d.ts +3 -0
  110. package/dist/src/step-name-symbol.d.ts.map +1 -1
  111. package/dist/src/step-name-symbol.js.map +1 -1
  112. package/dist/src/step-ref.d.ts +4 -0
  113. package/dist/src/step-ref.d.ts.map +1 -1
  114. package/dist/src/step-ref.js +2 -0
  115. package/dist/src/step-ref.js.map +1 -1
  116. package/dist/src/transports/bullmq/bullmq-transport.d.ts +130 -0
  117. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -0
  118. package/dist/src/transports/bullmq/bullmq-transport.js +562 -0
  119. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -0
  120. package/dist/src/transports/bullmq/deps.d.ts +59 -0
  121. package/dist/src/transports/bullmq/deps.d.ts.map +1 -0
  122. package/dist/src/transports/bullmq/deps.js +41 -0
  123. package/dist/src/transports/bullmq/deps.js.map +1 -0
  124. package/dist/src/transports/bullmq/index.d.ts +5 -0
  125. package/dist/src/transports/bullmq/index.d.ts.map +1 -0
  126. package/dist/src/transports/bullmq/index.js +5 -0
  127. package/dist/src/transports/bullmq/index.js.map +1 -0
  128. package/dist/src/transports/bullmq/naming.d.ts +71 -0
  129. package/dist/src/transports/bullmq/naming.d.ts.map +1 -0
  130. package/dist/src/transports/bullmq/naming.js +101 -0
  131. package/dist/src/transports/bullmq/naming.js.map +1 -0
  132. package/dist/src/transports/bullmq/serialization.d.ts +56 -0
  133. package/dist/src/transports/bullmq/serialization.d.ts.map +1 -0
  134. package/dist/src/transports/bullmq/serialization.js +101 -0
  135. package/dist/src/transports/bullmq/serialization.js.map +1 -0
  136. package/dist/src/transports/factory.d.ts +31 -0
  137. package/dist/src/transports/factory.d.ts.map +1 -1
  138. package/dist/src/transports/factory.js +21 -0
  139. package/dist/src/transports/factory.js.map +1 -1
  140. package/dist/src/worker-runtime/index.d.ts +22 -0
  141. package/dist/src/worker-runtime/index.d.ts.map +1 -0
  142. package/dist/src/worker-runtime/index.js +27 -0
  143. package/dist/src/worker-runtime/index.js.map +1 -0
  144. package/dist/src/worker-runtime/naming.d.ts +24 -0
  145. package/dist/src/worker-runtime/naming.d.ts.map +1 -0
  146. package/dist/src/worker-runtime/naming.js +28 -0
  147. package/dist/src/worker-runtime/naming.js.map +1 -0
  148. package/dist/src/worker-runtime/registry.d.ts +81 -0
  149. package/dist/src/worker-runtime/registry.d.ts.map +1 -0
  150. package/dist/src/worker-runtime/registry.js +44 -0
  151. package/dist/src/worker-runtime/registry.js.map +1 -0
  152. package/dist/src/worker-runtime/worker-runtime.d.ts +155 -0
  153. package/dist/src/worker-runtime/worker-runtime.d.ts.map +1 -0
  154. package/dist/src/worker-runtime/worker-runtime.js +265 -0
  155. package/dist/src/worker-runtime/worker-runtime.js.map +1 -0
  156. package/dist/src/workflow-ctx.d.ts +9 -0
  157. package/dist/src/workflow-ctx.d.ts.map +1 -1
  158. package/dist/src/workflow-ctx.js +78 -27
  159. package/dist/src/workflow-ctx.js.map +1 -1
  160. package/dist/src/workflow-turn.d.ts +167 -0
  161. package/dist/src/workflow-turn.d.ts.map +1 -0
  162. package/dist/src/workflow-turn.js +372 -0
  163. package/dist/src/workflow-turn.js.map +1 -0
  164. package/package.json +7 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @adonis-agora/durable
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Store-less cluster + cross-ecosystem interop: separate API from engine and run store-less "thin" pods that only talk to the control plane. New role-discriminated config (`standalone` / `control-plane` / `tenant`, `store?: never` on tenant → compile-time isolation), an aviary byte-compatible BullMQ transport (a Python aviary worker can share the same control plane, proven live both directions), the P4 RunGateway (Store/Proxy/Responder) request/reply protocol with layered tenant auth, a store-less WorkerRuntime running steps + workflow turns + parallel `gather` (subpath `@adonis-agora/durable/worker`, no Lucid), a worker-descriptor handshake with capability/protocol negotiation + capability-aware dispatch (park `blocked` instead of hanging), and a dashboard fleet-health panel. Adds `bullmq` as a dependency.
8
+
9
+ ## 0.10.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Parity sync from nestjs-durable: retry now re-executes failed runs (was a no-op), self-heal event-waiting suspends orphaned by a lost wake (`reconcileMs`), recover a remote step whose dispatched job was lost (`redispatchPending` + `remoteRedispatchMs`), cascade retry + retry-adoption, and `ctx.all` failFast cancels surviving siblings + `webhook().wait({ timeoutMs })`.
14
+
3
15
  ## 0.9.1
4
16
 
5
17
  ### Patch Changes
@@ -177,12 +177,26 @@
177
177
  font-size: 12.5px;
178
178
  }
179
179
  .health .h-card.stalled { border-color: var(--red); }
180
+ .nav { display: flex; gap: 16px; }
181
+ .nav a { color: var(--muted); font-size: 13px; }
182
+ .nav a:hover, .nav a.active { color: var(--text); }
183
+ /* Fleet-health / protocol-compat badges — mirror src/dashboard/compat-view.ts outcomeClass(). */
184
+ .badge.c-ok { color: var(--green); background: rgba(63,185,80,0.12); border-color: rgba(63,185,80,0.3); }
185
+ .badge.c-degraded { color: var(--amber); background: rgba(210,153,34,0.12); border-color: rgba(210,153,34,0.3); }
186
+ .badge.c-bad { color: var(--red); background: rgba(248,81,73,0.12); border-color: rgba(248,81,73,0.3); }
187
+ .panel.flagged-panel { border-color: var(--red); }
188
+ tr.flagged { background: rgba(248,81,73,0.06); }
189
+ .flag-reason { color: var(--red); }
180
190
  </style>
181
191
  </head>
182
192
  <body>
183
193
  <header>
184
194
  <h1><span class="dot">●</span> Durable</h1>
185
195
  <span class="muted mono" id="crumb">runs</span>
196
+ <nav class="nav">
197
+ <a href="#" id="nav-runs">Runs</a>
198
+ <a href="#/health" id="nav-health">Fleet health</a>
199
+ </nav>
186
200
  <span class="spacer"></span>
187
201
  <span class="muted" id="health-strip"></span>
188
202
  </header>
@@ -414,6 +428,98 @@
414
428
  if (childrenPanel) app.appendChild(childrenPanel);
415
429
  }
416
430
 
431
+ // Mirrors src/dashboard/compat-view.ts — keep the two in lockstep (unit-tested there).
432
+ const OUTCOME = {
433
+ compatible: { cls: 'c-ok', label: 'compatible' },
434
+ degraded: { cls: 'c-degraded', label: 'degraded' },
435
+ incompatible: { cls: 'c-bad', label: 'incompatible' },
436
+ };
437
+ const protoRange = (r) =>
438
+ !r ? '—' : (r[0] === r[1] ? 'v' + r[0] : 'v' + r[0] + '–' + r[1]);
439
+
440
+ async function renderCompat() {
441
+ document.getElementById('crumb').textContent = 'fleet health';
442
+ const app = document.getElementById('app');
443
+ let data;
444
+ try {
445
+ data = await api('/compat');
446
+ } catch (e) {
447
+ app.innerHTML = '';
448
+ app.appendChild(el('div', { class: 'empty' }, 'Failed to load fleet health: ' + e.message));
449
+ return;
450
+ }
451
+
452
+ const cp = data.controlPlane;
453
+ const cpPanel = el('div', { class: 'panel' }, [
454
+ el('h2', {}, 'Control plane'),
455
+ el('dl', { class: 'kv' }, [
456
+ el('dt', {}, 'Instance'), el('dd', {}, el('code', {}, cp.instanceId)),
457
+ el('dt', {}, 'Protocol'), el('dd', {}, protoRange(cp.protocolRange)),
458
+ el('dt', {}, 'Capabilities'),
459
+ el('dd', { class: 'muted' }, (cp.capabilities || []).join(', ') || '—'),
460
+ ]),
461
+ ]);
462
+
463
+ const groupPanels = data.groups.map((g) => {
464
+ const rows = g.pods.map((p) => {
465
+ const o = OUTCOME[p.outcome] || { cls: '', label: p.outcome };
466
+ return el('tr', p.incompatible ? { class: 'flagged' } : {}, [
467
+ el('td', {}, el('code', {}, p.instanceId)),
468
+ el('td', { class: 'muted' }, p.runtime),
469
+ el('td', {}, protoRange(p.protocolRange)),
470
+ el('td', {}, el('span', { class: 'badge ' + o.cls }, o.label)),
471
+ el('td', { class: p.incompatible ? 'flag-reason' : 'muted' }, p.reason || '—'),
472
+ ]);
473
+ });
474
+ return el('div', { class: 'panel' + (g.incompatible ? ' flagged-panel' : '') }, [
475
+ el('h2', {}, (g.incompatible ? '⚠ ' : '') + g.token),
476
+ el('table', {}, [
477
+ el('thead', {}, el('tr', {}, [
478
+ el('th', {}, 'Pod'), el('th', {}, 'Runtime'), el('th', {}, 'Protocol'),
479
+ el('th', {}, 'Negotiated'), el('th', {}, 'Reason'),
480
+ ])),
481
+ el('tbody', {}, rows),
482
+ ]),
483
+ ]);
484
+ });
485
+
486
+ const blockedRows = data.blocked.map((b) =>
487
+ el('tr', { onclick: () => navigate(b.id) }, [
488
+ el('td', {}, el('code', {}, b.id)),
489
+ el('td', {}, b.workflow || '—'),
490
+ el('td', { class: 'flag-reason' }, b.reason),
491
+ el('td', { class: 'muted' }, (b.requires && b.requires.length) ? b.requires.join(', ') : '—'),
492
+ el('td', { class: 'muted mono' }, fmt(b.updatedAt)),
493
+ ]));
494
+ const blockedPanel = el('div', { class: 'panel' + (data.blocked.length ? ' flagged-panel' : '') }, [
495
+ el('h2', {}, (data.blocked.length ? '⚠ ' : '') + 'Blocked runs (' + data.blocked.length + ')'),
496
+ data.blocked.length
497
+ ? el('table', {}, [
498
+ el('thead', {}, el('tr', {}, [
499
+ el('th', {}, 'Run id'), el('th', {}, 'Workflow'), el('th', {}, 'Reason'),
500
+ el('th', {}, 'Requires'), el('th', {}, 'Updated'),
501
+ ])),
502
+ el('tbody', {}, blockedRows),
503
+ ])
504
+ : el('div', { class: 'muted' }, 'No blocked runs — every run has a compatible worker.'),
505
+ ]);
506
+
507
+ app.innerHTML = '';
508
+ app.appendChild(el('a', {
509
+ class: 'back', href: '#',
510
+ onclick: (e) => { e.preventDefault(); navigate(''); },
511
+ }, '← all runs'));
512
+ app.appendChild(cpPanel);
513
+ for (const panel of (data.groups.length ? groupPanels
514
+ : [el('div', { class: 'panel' }, [
515
+ el('h2', {}, 'Workers'),
516
+ el('div', { class: 'muted' }, 'No live worker descriptors advertised.'),
517
+ ])])) {
518
+ app.appendChild(panel);
519
+ }
520
+ app.appendChild(blockedPanel);
521
+ }
522
+
417
523
  async function act(action, id) {
418
524
  try {
419
525
  await api('/runs/' + encodeURIComponent(id) + '/' + action, {
@@ -433,9 +539,16 @@
433
539
 
434
540
  function route() {
435
541
  const hash = location.hash.replace(/^#/, '');
436
- const m = /^\/runs\/(.+)$/.exec(hash);
437
- if (m) renderDetail(decodeURIComponent(m[1]));
438
- else renderList();
542
+ const onHealth = hash === '/health';
543
+ document.getElementById('nav-health').className = onHealth ? 'active' : '';
544
+ document.getElementById('nav-runs').className = onHealth ? '' : 'active';
545
+ if (onHealth) {
546
+ renderCompat();
547
+ } else {
548
+ const m = /^\/runs\/(.+)$/.exec(hash);
549
+ if (m) renderDetail(decodeURIComponent(m[1]));
550
+ else renderList();
551
+ }
439
552
  refreshHealth();
440
553
  }
441
554
 
@@ -0,0 +1,25 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import type { CommandOptions } from '@adonisjs/core/types/ace';
3
+ /**
4
+ * `node ace durable:worker` — the **store-less** task-consumer loop for a `role: 'tenant'` worker pod
5
+ * (design §3). Distinct from `durable:work` (the store-backed dispatch/recovery/timers loop, which
6
+ * this command deliberately does NOT clobber): a store-less worker owns no durable state — it consumes
7
+ * `${P}-tasks-<token>` tasks (executed through the shared `runStepHandler`, results published) and
8
+ * advertises its {@link import('../src/handshake/descriptor.js').WorkerDescriptor} + heartbeat. Stays
9
+ * alive until SIGINT/SIGTERM, then drains.
10
+ *
11
+ * On a `role: 'tenant'` pod it resolves the **container-bound {@link WorkerRuntime}** the provider wired
12
+ * (design §5): that runtime shares the tenant transport with the `ProxyRunGateway` and carries a
13
+ * `RedisWorkerRegistry` built from the transport's Redis connection, so descriptor + heartbeat are
14
+ * actually published on the shared Redis. Only when no bound runtime exists (a non-tenant role, or a
15
+ * misconfigured pod) does it fall back to building a self-contained runtime from `config/durable.ts`
16
+ * with a {@link NoopWorkerRegistry} (advertising is then a no-op).
17
+ */
18
+ export default class DurableWorker extends BaseCommand {
19
+ #private;
20
+ static commandName: string;
21
+ static description: string;
22
+ static options: CommandOptions;
23
+ run(): Promise<void>;
24
+ }
25
+ //# sourceMappingURL=durable_worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable_worker.d.ts","sourceRoot":"","sources":["../../commands/durable_worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAU/D;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,WAAW;;IACpD,OAAgB,WAAW,SAAoB;IAC/C,OAAgB,WAAW,SACsE;IACjG,OAAgB,OAAO,EAAE,cAAc,CAAwC;IAEhE,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;CA+GpC"}
@@ -0,0 +1,113 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import { DURABLE_WORKER_RUNTIME } from '../src/role_bindings.js';
3
+ import { InMemoryTransport } from '../src/testing/in-memory-transport.js';
4
+ import { NoopWorkerRegistry } from '../src/worker-runtime/index.js';
5
+ import { registerStepsFromDir } from '../src/worker-runtime/index.js';
6
+ import { WorkerRuntime } from '../src/worker-runtime/index.js';
7
+ import { discoverWorkflows } from '../src/workflow-discovery.js';
8
+ /**
9
+ * `node ace durable:worker` — the **store-less** task-consumer loop for a `role: 'tenant'` worker pod
10
+ * (design §3). Distinct from `durable:work` (the store-backed dispatch/recovery/timers loop, which
11
+ * this command deliberately does NOT clobber): a store-less worker owns no durable state — it consumes
12
+ * `${P}-tasks-<token>` tasks (executed through the shared `runStepHandler`, results published) and
13
+ * advertises its {@link import('../src/handshake/descriptor.js').WorkerDescriptor} + heartbeat. Stays
14
+ * alive until SIGINT/SIGTERM, then drains.
15
+ *
16
+ * On a `role: 'tenant'` pod it resolves the **container-bound {@link WorkerRuntime}** the provider wired
17
+ * (design §5): that runtime shares the tenant transport with the `ProxyRunGateway` and carries a
18
+ * `RedisWorkerRegistry` built from the transport's Redis connection, so descriptor + heartbeat are
19
+ * actually published on the shared Redis. Only when no bound runtime exists (a non-tenant role, or a
20
+ * misconfigured pod) does it fall back to building a self-contained runtime from `config/durable.ts`
21
+ * with a {@link NoopWorkerRegistry} (advertising is then a no-op).
22
+ */
23
+ export default class DurableWorker extends BaseCommand {
24
+ static commandName = 'durable:worker';
25
+ static description = 'Run the store-less durable worker (consume tasks, execute step bodies, advertise descriptor)';
26
+ static options = { startApp: true, staysAlive: true };
27
+ async run() {
28
+ const config = this.app.config.get('durable', {});
29
+ if (config.role !== 'tenant') {
30
+ this.logger.warning(`durable:worker is the store-less worker for role 'tenant'; config.role is '${config.role ?? 'standalone'}'. Continuing, but a store-backed role should use durable:work.`);
31
+ }
32
+ const { runtime, partition } = await this.#resolveRuntime(config);
33
+ // Register `app/steps` (served by the transport) + advertise `app/workflows` names.
34
+ if (config.stepsPath !== false) {
35
+ const stepsDir = this.app.makePath(config.stepsPath ?? 'app/steps');
36
+ const steps = await registerStepsFromDir(runtime, stepsDir);
37
+ this.logger.info(`registered ${steps.length} step handler(s) from ${config.stepsPath ?? 'app/steps'}`);
38
+ }
39
+ if (config.workflowsPath !== false) {
40
+ const workflowsDir = this.app.makePath(config.workflowsPath ?? 'app/workflows');
41
+ const workflows = await discoverWorkflows(workflowsDir);
42
+ runtime.registerWorkflowNames(workflows.map((w) => w.meta.name));
43
+ this.logger.info(`advertising ${workflows.length} workflow name(s) from ${config.workflowsPath ?? 'app/workflows'}`);
44
+ }
45
+ let stop;
46
+ const stopSignal = new Promise((resolve) => {
47
+ stop = resolve;
48
+ });
49
+ const onSignal = () => {
50
+ this.logger.info('shutdown signal received — stopping store-less worker…');
51
+ stop();
52
+ };
53
+ process.once('SIGINT', onSignal);
54
+ process.once('SIGTERM', onSignal);
55
+ await runtime.start();
56
+ this.logger.info(`durable:worker started (partition ${partition})`);
57
+ await stopSignal;
58
+ await runtime.stop();
59
+ process.off('SIGINT', onSignal);
60
+ process.off('SIGTERM', onSignal);
61
+ await this.terminate();
62
+ }
63
+ /**
64
+ * Prefer the provider-bound {@link WorkerRuntime} (the `role: 'tenant'` branch of `durable_provider`):
65
+ * it shares the tenant transport with the `ProxyRunGateway` and advertises through a real
66
+ * `RedisWorkerRegistry`. Fall back to a self-built runtime (a {@link NoopWorkerRegistry}) only when the
67
+ * container has no bound runtime — a non-tenant role, or a pod running this command by mistake.
68
+ */
69
+ async #resolveRuntime(config) {
70
+ if (this.app.container.hasBinding(DURABLE_WORKER_RUNTIME)) {
71
+ const runtime = await this.app.container.make(DURABLE_WORKER_RUNTIME);
72
+ const partition = config.partition ?? config.namespace ?? 'default';
73
+ return { runtime, partition };
74
+ }
75
+ return this.#buildFallbackRuntime(config);
76
+ }
77
+ /** Build a self-contained runtime from `config/durable.ts` (no bound runtime available). Advertising is
78
+ * a no-op ({@link NoopWorkerRegistry}) — the descriptor is still built + observable, just unpublished. */
79
+ async #buildFallbackRuntime(config) {
80
+ const transport = await this.#resolveTransport(config);
81
+ if (typeof transport.handle !== 'function') {
82
+ throw new Error(`@agora/durable: the selected transport ("${config.transport ?? 'memory'}") cannot serve handlers (no handle()), so a store-less worker has nothing to consume.`);
83
+ }
84
+ transport.useNamespace?.(config.namespace ?? 'default');
85
+ const partition = config.partition ?? config.namespace ?? 'default';
86
+ const runtime = new WorkerRuntime({
87
+ transport: transport,
88
+ partition,
89
+ ...(config.namespace !== undefined ? { namespace: config.namespace } : {}),
90
+ ...(config.instanceId !== undefined ? { instanceId: config.instanceId } : {}),
91
+ ...(config.capabilities !== undefined
92
+ ? { capabilities: config.capabilities }
93
+ : {}),
94
+ registry: new NoopWorkerRegistry(),
95
+ logger: { info: (m) => this.logger.info(m), error: (m) => this.logger.error(m) },
96
+ onError: (err) => this.logger.error(`worker-runtime advertisement error: ${String(err)}`),
97
+ });
98
+ return { runtime, partition };
99
+ }
100
+ /** Build the transport from `config/durable.ts` — the same resolution the provider uses, so the
101
+ * worker lands on the SAME queues as the control-plane. In-memory default when no transport named. */
102
+ async #resolveTransport(config) {
103
+ const name = config.transport;
104
+ if (!name)
105
+ return new InMemoryTransport();
106
+ const factory = config.transports?.[name];
107
+ if (!factory) {
108
+ throw new Error(`@agora/durable: config.transport is "${name}", but config.transports.${name} is not defined`);
109
+ }
110
+ return factory({ app: this.app });
111
+ }
112
+ }
113
+ //# sourceMappingURL=durable_worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable_worker.js","sourceRoot":"","sources":["../../commands/durable_worker.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { ListLoader } from '@adonisjs/core/ace';
2
+ import DurableWorker from './durable_worker.js';
2
3
  import MakeWorkflow from './make_workflow.js';
3
4
  import DurableRetry from './retry.js';
4
5
  import DurableRuns from './runs.js';
@@ -6,12 +7,12 @@ import DurableWork from './work.js';
6
7
  /**
7
8
  * The commands barrel for `@adonis-agora/durable`. Registered in an app's `adonisrc` via
8
9
  * `rcFile.addCommand('@adonis-agora/durable/commands')` (done by this package's `configure`). The ace
9
- * kernel imports this module and treats it as a commands loader: a {@link ListLoader} over the four
10
- * durable commands (`durable:work`, `durable:runs`, `durable:retry`, `make:workflow`) provides their
11
- * metadata and constructors.
10
+ * kernel imports this module and treats it as a commands loader: a {@link ListLoader} over the durable
11
+ * commands (`durable:work`, `durable:worker`, `durable:runs`, `durable:retry`, `make:workflow`)
12
+ * provides their metadata and constructors.
12
13
  */
13
- declare const loader: ListLoader<typeof MakeWorkflow | typeof DurableRetry | typeof DurableRuns | typeof DurableWork>;
14
+ declare const loader: ListLoader<typeof DurableWorker | typeof MakeWorkflow | typeof DurableRetry | typeof DurableRuns | typeof DurableWork>;
14
15
  export declare const getMetaData: () => Promise<import("@adonisjs/core/types/ace").CommandMetaData[]>;
15
- export declare const getCommand: (metaData: import("@adonisjs/core/types/ace").CommandMetaData) => Promise<typeof MakeWorkflow | typeof DurableRetry | typeof DurableRuns | typeof DurableWork | null>;
16
+ export declare const getCommand: (metaData: import("@adonisjs/core/types/ace").CommandMetaData) => Promise<typeof DurableWorker | typeof MakeWorkflow | typeof DurableRetry | typeof DurableRuns | typeof DurableWork | null>;
16
17
  export default loader;
17
18
  //# sourceMappingURL=main.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,YAAY,MAAM,oBAAoB,CAAC;AAC9C,OAAO,YAAY,MAAM,YAAY,CAAC;AACtC,OAAO,WAAW,MAAM,WAAW,CAAC;AACpC,OAAO,WAAW,MAAM,WAAW,CAAC;AAEpC;;;;;;GAMG;AACH,QAAA,MAAM,MAAM,iGAAyE,CAAC;AAEtF,eAAO,MAAM,WAAW,qEAAkC,CAAC;AAC3D,eAAO,MAAM,UAAU,uKAAiC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAChD,OAAO,YAAY,MAAM,oBAAoB,CAAC;AAC9C,OAAO,YAAY,MAAM,YAAY,CAAC;AACtC,OAAO,WAAW,MAAM,WAAW,CAAC;AACpC,OAAO,WAAW,MAAM,WAAW,CAAC;AAEpC;;;;;;GAMG;AACH,QAAA,MAAM,MAAM,wHAMV,CAAC;AAEH,eAAO,MAAM,WAAW,qEAAkC,CAAC;AAC3D,eAAO,MAAM,UAAU,8LAAiC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import { ListLoader } from '@adonisjs/core/ace';
2
+ import DurableWorker from './durable_worker.js';
2
3
  import MakeWorkflow from './make_workflow.js';
3
4
  import DurableRetry from './retry.js';
4
5
  import DurableRuns from './runs.js';
@@ -6,11 +7,17 @@ import DurableWork from './work.js';
6
7
  /**
7
8
  * The commands barrel for `@adonis-agora/durable`. Registered in an app's `adonisrc` via
8
9
  * `rcFile.addCommand('@adonis-agora/durable/commands')` (done by this package's `configure`). The ace
9
- * kernel imports this module and treats it as a commands loader: a {@link ListLoader} over the four
10
- * durable commands (`durable:work`, `durable:runs`, `durable:retry`, `make:workflow`) provides their
11
- * metadata and constructors.
10
+ * kernel imports this module and treats it as a commands loader: a {@link ListLoader} over the durable
11
+ * commands (`durable:work`, `durable:worker`, `durable:runs`, `durable:retry`, `make:workflow`)
12
+ * provides their metadata and constructors.
12
13
  */
13
- const loader = new ListLoader([DurableWork, DurableRuns, DurableRetry, MakeWorkflow]);
14
+ const loader = new ListLoader([
15
+ DurableWork,
16
+ DurableWorker,
17
+ DurableRuns,
18
+ DurableRetry,
19
+ MakeWorkflow,
20
+ ]);
14
21
  export const getMetaData = loader.getMetaData.bind(loader);
15
22
  export const getCommand = loader.getCommand.bind(loader);
16
23
  export default loader;
@@ -1 +1 @@
1
- {"version":3,"file":"main.js","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,YAAY,MAAM,oBAAoB,CAAC;AAC9C,OAAO,YAAY,MAAM,YAAY,CAAC;AACtC,OAAO,WAAW,MAAM,WAAW,CAAC;AACpC,OAAO,WAAW,MAAM,WAAW,CAAC;AAEpC;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC;AAEtF,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC3D,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,aAAa,MAAM,qBAAqB,CAAC;AAChD,OAAO,YAAY,MAAM,oBAAoB,CAAC;AAC9C,OAAO,YAAY,MAAM,YAAY,CAAC;AACtC,OAAO,WAAW,MAAM,WAAW,CAAC;AACpC,OAAO,WAAW,MAAM,WAAW,CAAC;AAEpC;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC;IAC5B,WAAW;IACX,aAAa;IACb,WAAW;IACX,YAAY;IACZ,YAAY;CACb,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC3D,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
@@ -10,6 +10,7 @@ import type { ApplicationService } from '@adonisjs/core/types';
10
10
  * - `GET /api/runs` → list runs (status/workflow/tag filters, paged)
11
11
  * - `GET /api/runs/:id` → run detail (run + step timeline + children)
12
12
  * - `POST /api/runs/:id/retry` → re-enqueue the run
13
+ * - `POST /api/runs/:id/redispatch` → re-dispatch a run's lost pending remote steps
13
14
  * - `POST /api/runs/:id/cancel` → cancel the run
14
15
  * - `GET /api/health` → worker-group health
15
16
  */
@@ -18,8 +19,43 @@ export default class DashboardProvider {
18
19
  constructor(app: ApplicationService);
19
20
  /** Warn once so a throwing `login` hook doesn't spam the logs on every failed attempt. */
20
21
  private warnedOnHookThrow;
22
+ /**
23
+ * Captures the engine's `capability.unavailable` / `protocol.incompatible` diagnostics events so the
24
+ * `/compat` health panel can render each blocked run's structured delta + the live-fleet compat view
25
+ * (design §7.6, §10). Store-role only — attached in {@link boot}; a `tenant` pod owns no engine.
26
+ */
27
+ private readonly diagnostics;
28
+ /** Detach handle for the diagnostics subscription, released on {@link shutdown}. */
29
+ private detachDiagnostics;
30
+ /** The active durable role (`standalone` when no `durable` config is present — today's default). */
31
+ private durableRole;
21
32
  boot(): Promise<void>;
33
+ /**
34
+ * Subscribe the {@link diagnostics} recorder to the store-backed engine's lifecycle events so the
35
+ * `/compat` panel is fed live (design §10). Store-role only: a `tenant` pod owns no engine (structural
36
+ * isolation), so its panel shows blocked runs reason-only. Defensive: if the engine can't be resolved
37
+ * (the dashboard mounted without `@adonis-agora/durable`'s provider), the recorder simply stays empty
38
+ * rather than crashing boot.
39
+ */
40
+ private attachDiagnostics;
22
41
  private registerRoutes;
42
+ /**
43
+ * Assemble the {@link CompatSource} the `/compat` handler reads (design §10). The fleet view is the LIVE
44
+ * green fleet enumerated off the transport (every advertising worker — compatible OR incompatible, even
45
+ * one that never triggered a blocked dispatch) merged with the descriptors the {@link diagnostics}
46
+ * recorder reconstructed from past blocks; the live descriptors win per instance (fresher). Captured
47
+ * diagnostics + blocked runs stay as they were.
48
+ *
49
+ * The live enumeration is snapshotted ONCE per request (an `async` fetch off the transport), then handed
50
+ * to the handler as a synchronous `fleet()` closure — so {@link CompatSource} keeps its cheap sync shape.
51
+ * A `tenant` pod (or a transport without the fleet capability) has no {@link DURABLE_TRANSPORT} binding →
52
+ * live fleet is empty → the panel degrades to the diagnostics-only view exactly as before.
53
+ */
54
+ private compatSource;
55
+ /** Resolve the store role's task transport (published under {@link DURABLE_TRANSPORT}) for LIVE fleet
56
+ * enumeration, or `null` when unavailable — a `tenant` pod never binds it, and a mis-mounted dashboard
57
+ * degrades cleanly to the diagnostics-only view rather than crashing the panel. */
58
+ private fleetTransport;
23
59
  /**
24
60
  * Mount the built-in `dashboardAuth` endpoints under `basePath`. All three are public (no guard):
25
61
  * they create/destroy the session the {@link enforce} guard checks for.
@@ -54,5 +90,7 @@ export default class DashboardProvider {
54
90
  * `Secure` on https. `Path=/` so it reaches both the UI and API mounts regardless of prefix.
55
91
  */
56
92
  private writeSessionCookie;
93
+ /** Detach the diagnostics subscription so the recorder stops listening on a clean shutdown/redeploy. */
94
+ shutdown(): Promise<void>;
57
95
  }
58
96
  //# sourceMappingURL=dashboard_provider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard_provider.d.ts","sourceRoot":"","sources":["../../providers/dashboard_provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AA0BlF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAiB;IACxB,SAAS,CAAC,GAAG,EAAE,kBAAkB;gBAAvB,GAAG,EAAE,kBAAkB;IAE7C,0FAA0F;IAC1F,OAAO,CAAC,iBAAiB,CAAS;IAE5B,IAAI;IA2BV,OAAO,CAAC,cAAc;IAwDtB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,kBAAkB;IAiD1B;;;;;;;;;;OAUG;YACW,OAAO;IA0BrB,iFAAiF;IACjF,OAAO,CAAC,iBAAiB;IAKzB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;CAU3B"}
1
+ {"version":3,"file":"dashboard_provider.d.ts","sourceRoot":"","sources":["../../providers/dashboard_provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AAuClF;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,OAAO,OAAO,iBAAiB;IACxB,SAAS,CAAC,GAAG,EAAE,kBAAkB;gBAAvB,GAAG,EAAE,kBAAkB;IAE7C,0FAA0F;IAC1F,OAAO,CAAC,iBAAiB,CAAS;IAElC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoC;IAChE,oFAAoF;IACpF,OAAO,CAAC,iBAAiB,CAA6B;IAEtD,oGAAoG;IACpG,OAAO,CAAC,WAAW;IAIb,IAAI;IA8BV;;;;;;OAMG;YACW,iBAAiB;IAU/B,OAAO,CAAC,cAAc;IA8EtB;;;;;;;;;;;OAWG;YACW,YAAY;IAW1B;;wFAEoF;YACtE,cAAc;IAQ5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,kBAAkB;IAiD1B;;;;;;;;;;OAUG;YACW,OAAO;IA0BrB,iFAAiF;IACjF,OAAO,CAAC,iBAAiB;IAKzB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;IAW1B,wGAAwG;IAClG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAIhC"}
@@ -1,9 +1,13 @@
1
1
  import { SESSION_COOKIE_NAME, performLogin, readSession, } from '../src/dashboard/auth.js';
2
+ import { compat, enumerateLiveFleet, mergeFleets, } from '../src/dashboard/compat.js';
2
3
  import { resolveConfig, } from '../src/dashboard/define_config.js';
3
- import { cancelRun, getRun, health, listRuns, retryRun, } from '../src/dashboard/handlers.js';
4
+ import { BlockedDiagnosticsRecorder } from '../src/dashboard/diagnostics-recorder.js';
5
+ import { dashboardEngineForRole } from '../src/dashboard/gateway-adapter.js';
6
+ import { cancelRun, getRun, health, listRuns, redispatchPendingRun, retryRun, } from '../src/dashboard/handlers.js';
4
7
  import { renderDashboard } from '../src/dashboard/html.js';
5
8
  import { renderLoginPage } from '../src/dashboard/login_page.js';
6
9
  import { WorkflowEngine } from '../src/index.js';
10
+ import { DURABLE_TRANSPORT } from '../src/role_bindings.js';
7
11
  /**
8
12
  * Mounts the durable dashboard into an AdonisJS app: a JSON API over the
9
13
  * {@link WorkflowEngine}'s read surface, and a single self-contained HTML page
@@ -15,6 +19,7 @@ import { WorkflowEngine } from '../src/index.js';
15
19
  * - `GET /api/runs` → list runs (status/workflow/tag filters, paged)
16
20
  * - `GET /api/runs/:id` → run detail (run + step timeline + children)
17
21
  * - `POST /api/runs/:id/retry` → re-enqueue the run
22
+ * - `POST /api/runs/:id/redispatch` → re-dispatch a run's lost pending remote steps
18
23
  * - `POST /api/runs/:id/cancel` → cancel the run
19
24
  * - `GET /api/health` → worker-group health
20
25
  */
@@ -25,10 +30,23 @@ export default class DashboardProvider {
25
30
  }
26
31
  /** Warn once so a throwing `login` hook doesn't spam the logs on every failed attempt. */
27
32
  warnedOnHookThrow = false;
33
+ /**
34
+ * Captures the engine's `capability.unavailable` / `protocol.incompatible` diagnostics events so the
35
+ * `/compat` health panel can render each blocked run's structured delta + the live-fleet compat view
36
+ * (design §7.6, §10). Store-role only — attached in {@link boot}; a `tenant` pod owns no engine.
37
+ */
38
+ diagnostics = new BlockedDiagnosticsRecorder();
39
+ /** Detach handle for the diagnostics subscription, released on {@link shutdown}. */
40
+ detachDiagnostics = null;
41
+ /** The active durable role (`standalone` when no `durable` config is present — today's default). */
42
+ durableRole() {
43
+ return this.app.config.get('durable', {}).role ?? 'standalone';
44
+ }
28
45
  async boot() {
29
46
  const config = resolveConfig(this.app.config.get('durable_dashboard', {}));
30
47
  if (!config.enabled)
31
48
  return;
49
+ const role = this.durableRole();
32
50
  // Route registration can't happen synchronously in `boot()`: at this point in the AdonisJS
33
51
  // lifecycle the HTTP server/router binding may not be resolvable yet (bindings from other
34
52
  // providers' `boot()` methods can still be pending), and — critically — the *documented*
@@ -46,18 +64,35 @@ export default class DashboardProvider {
46
64
  // `@adonisjs/core`'s own `AppServiceProvider` uses internally.
47
65
  await this.app.booted(async () => {
48
66
  const router = await this.app.container.make('router');
49
- this.registerRoutes(router, config);
67
+ this.registerRoutes(router, config, role);
68
+ await this.attachDiagnostics(role);
50
69
  });
51
70
  }
52
- registerRoutes(router, config) {
53
- const apiBase = `${config.path}/api`;
54
- // Resolve the engine lazily per request: the singleton is built by
55
- // @adonis-agora/durable's provider, and runs/checkpoints are read through its own
56
- // read API (listRuns / listCheckpoints), so the dashboard needs nothing else.
57
- const deps = async () => {
71
+ /**
72
+ * Subscribe the {@link diagnostics} recorder to the store-backed engine's lifecycle events so the
73
+ * `/compat` panel is fed live (design §10). Store-role only: a `tenant` pod owns no engine (structural
74
+ * isolation), so its panel shows blocked runs reason-only. Defensive: if the engine can't be resolved
75
+ * (the dashboard mounted without `@adonis-agora/durable`'s provider), the recorder simply stays empty
76
+ * rather than crashing boot.
77
+ */
78
+ async attachDiagnostics(role) {
79
+ if (role === 'tenant')
80
+ return;
81
+ try {
58
82
  const engine = await this.app.container.make(WorkflowEngine);
59
- return { engine };
60
- };
83
+ this.detachDiagnostics = this.diagnostics.attach(engine);
84
+ }
85
+ catch {
86
+ // No store-backed engine available — the compat panel degrades to reason-only blocked runs.
87
+ }
88
+ }
89
+ registerRoutes(router, config, role) {
90
+ const apiBase = `${config.path}/api`;
91
+ // Resolve the dashboard's read/control port lazily per request, branched by role (design §5/§8):
92
+ // a store role resolves the engine (built by @adonis-agora/durable's provider); a store-less `tenant`
93
+ // pod resolves the DURABLE_RUN_GATEWAY token (a ProxyRunGateway) and adapts it — NO engine, NO store.
94
+ const resolveEngine = () => dashboardEngineForRole(role, this.app.container, WorkflowEngine);
95
+ const deps = async () => ({ engine: await resolveEngine() });
61
96
  // Built-in `dashboardAuth` login screen (opt-in). Registered ONLY when configured, so omitting
62
97
  // `dashboardAuth` leaves route registration byte-for-byte as it was. These endpoints are public
63
98
  // (behind NEITHER guard): they MINT the session the guard checks for, and the login page must
@@ -94,10 +129,63 @@ export default class DashboardProvider {
94
129
  router.get(`${apiBase}/runs`, json(listRuns)).as('durable_dashboard.runs.index');
95
130
  router.get(`${apiBase}/runs/:id`, json(getRun)).as('durable_dashboard.runs.show');
96
131
  router.post(`${apiBase}/runs/:id/retry`, json(retryRun)).as('durable_dashboard.runs.retry');
132
+ router
133
+ .post(`${apiBase}/runs/:id/redispatch`, json(redispatchPendingRun))
134
+ .as('durable_dashboard.runs.redispatch');
97
135
  router.post(`${apiBase}/runs/:id/cancel`, json(cancelRun)).as('durable_dashboard.runs.cancel');
98
136
  router
99
137
  .get(`${apiBase}/health`, json((d) => health(d)))
100
138
  .as('durable_dashboard.health');
139
+ // Fleet health / protocol-compatibility panel (design §7.6, §10): per queue/group/pod protocol +
140
+ // negotiated level + red-flag reason on incompatibility, plus blocked runs with their structured delta.
141
+ router
142
+ .get(`${apiBase}/compat`, async (ctx) => {
143
+ if (!(await this.enforce(config, ctx, 'api')))
144
+ return;
145
+ try {
146
+ const result = await compat(await this.compatSource(await resolveEngine()));
147
+ return ctx.response.status(result.status).json(result.body);
148
+ }
149
+ catch (error) {
150
+ return ctx.response
151
+ .status(500)
152
+ .json({ error: error instanceof Error ? error.message : 'internal error' });
153
+ }
154
+ })
155
+ .as('durable_dashboard.compat');
156
+ }
157
+ /**
158
+ * Assemble the {@link CompatSource} the `/compat` handler reads (design §10). The fleet view is the LIVE
159
+ * green fleet enumerated off the transport (every advertising worker — compatible OR incompatible, even
160
+ * one that never triggered a blocked dispatch) merged with the descriptors the {@link diagnostics}
161
+ * recorder reconstructed from past blocks; the live descriptors win per instance (fresher). Captured
162
+ * diagnostics + blocked runs stay as they were.
163
+ *
164
+ * The live enumeration is snapshotted ONCE per request (an `async` fetch off the transport), then handed
165
+ * to the handler as a synchronous `fleet()` closure — so {@link CompatSource} keeps its cheap sync shape.
166
+ * A `tenant` pod (or a transport without the fleet capability) has no {@link DURABLE_TRANSPORT} binding →
167
+ * live fleet is empty → the panel degrades to the diagnostics-only view exactly as before.
168
+ */
169
+ async compatSource(engine) {
170
+ const live = await enumerateLiveFleet(await this.fleetTransport());
171
+ const fleet = mergeFleets(this.diagnostics.fleet(), live);
172
+ return {
173
+ controlPlaneDescriptor: () => this.diagnostics.controlPlaneDescriptor(),
174
+ fleet: () => fleet,
175
+ blockedRuns: () => engine.listRuns({ statuses: ['blocked'] }),
176
+ diagnosticsFor: (runId) => this.diagnostics.diagnosticsFor(runId),
177
+ };
178
+ }
179
+ /** Resolve the store role's task transport (published under {@link DURABLE_TRANSPORT}) for LIVE fleet
180
+ * enumeration, or `null` when unavailable — a `tenant` pod never binds it, and a mis-mounted dashboard
181
+ * degrades cleanly to the diagnostics-only view rather than crashing the panel. */
182
+ async fleetTransport() {
183
+ try {
184
+ return (await this.app.container.make(DURABLE_TRANSPORT));
185
+ }
186
+ catch {
187
+ return null;
188
+ }
101
189
  }
102
190
  /**
103
191
  * Mount the built-in `dashboardAuth` endpoints under `basePath`. All three are public (no guard):
@@ -203,6 +291,11 @@ export default class DashboardProvider {
203
291
  encode: false,
204
292
  });
205
293
  }
294
+ /** Detach the diagnostics subscription so the recorder stops listening on a clean shutdown/redeploy. */
295
+ async shutdown() {
296
+ this.detachDiagnostics?.();
297
+ this.detachDiagnostics = null;
298
+ }
206
299
  }
207
300
  /** Adapt an AdonisJS `HttpContext` to the framework-light {@link ApiRequest}. */
208
301
  function toApiRequest(ctx) {