@adonis-agora/durable 0.7.0 → 0.8.1

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 (120) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +1 -1
  3. package/dist/commands/main.d.ts +3 -2
  4. package/dist/commands/main.d.ts.map +1 -1
  5. package/dist/commands/main.js +3 -2
  6. package/dist/commands/main.js.map +1 -1
  7. package/dist/commands/make_workflow.d.ts +4 -3
  8. package/dist/commands/make_workflow.d.ts.map +1 -1
  9. package/dist/commands/make_workflow.js +4 -3
  10. package/dist/commands/make_workflow.js.map +1 -1
  11. package/dist/configure.d.ts +6 -5
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +9 -6
  14. package/dist/configure.js.map +1 -1
  15. package/dist/providers/dashboard_provider.d.ts +34 -3
  16. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  17. package/dist/providers/dashboard_provider.js +130 -9
  18. package/dist/providers/dashboard_provider.js.map +1 -1
  19. package/dist/src/base-workflow.d.ts +78 -0
  20. package/dist/src/base-workflow.d.ts.map +1 -0
  21. package/dist/src/base-workflow.js +106 -0
  22. package/dist/src/base-workflow.js.map +1 -0
  23. package/dist/src/codec-state-store.d.ts +13 -0
  24. package/dist/src/codec-state-store.d.ts.map +1 -1
  25. package/dist/src/codec-state-store.js +13 -0
  26. package/dist/src/codec-state-store.js.map +1 -1
  27. package/dist/src/control-flow-signal.d.ts +50 -0
  28. package/dist/src/control-flow-signal.d.ts.map +1 -0
  29. package/dist/src/control-flow-signal.js +54 -0
  30. package/dist/src/control-flow-signal.js.map +1 -0
  31. package/dist/src/control-plane-redis/redis-control-plane.d.ts +57 -5
  32. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  33. package/dist/src/control-plane-redis/redis-control-plane.js +100 -2
  34. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  35. package/dist/src/dashboard/auth.d.ts +89 -0
  36. package/dist/src/dashboard/auth.d.ts.map +1 -0
  37. package/dist/src/dashboard/auth.js +113 -0
  38. package/dist/src/dashboard/auth.js.map +1 -0
  39. package/dist/src/dashboard/define_config.d.ts +17 -0
  40. package/dist/src/dashboard/define_config.d.ts.map +1 -1
  41. package/dist/src/dashboard/define_config.js +23 -1
  42. package/dist/src/dashboard/define_config.js.map +1 -1
  43. package/dist/src/dashboard/index.d.ts +5 -0
  44. package/dist/src/dashboard/index.d.ts.map +1 -1
  45. package/dist/src/dashboard/index.js +4 -0
  46. package/dist/src/dashboard/index.js.map +1 -1
  47. package/dist/src/dashboard/login_page.d.ts +23 -0
  48. package/dist/src/dashboard/login_page.d.ts.map +1 -0
  49. package/dist/src/dashboard/login_page.js +170 -0
  50. package/dist/src/dashboard/login_page.js.map +1 -0
  51. package/dist/src/dashboard/session_cookie.d.ts +42 -0
  52. package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
  53. package/dist/src/dashboard/session_cookie.js +97 -0
  54. package/dist/src/dashboard/session_cookie.js.map +1 -0
  55. package/dist/src/define_config.d.ts +3 -2
  56. package/dist/src/define_config.d.ts.map +1 -1
  57. package/dist/src/define_config.js.map +1 -1
  58. package/dist/src/engine.d.ts +51 -1
  59. package/dist/src/engine.d.ts.map +1 -1
  60. package/dist/src/engine.js +213 -27
  61. package/dist/src/engine.js.map +1 -1
  62. package/dist/src/errors.d.ts +6 -1
  63. package/dist/src/errors.d.ts.map +1 -1
  64. package/dist/src/errors.js +7 -2
  65. package/dist/src/errors.js.map +1 -1
  66. package/dist/src/event-accumulators.d.ts +1 -1
  67. package/dist/src/event-accumulators.d.ts.map +1 -1
  68. package/dist/src/events.d.ts +16 -0
  69. package/dist/src/events.d.ts.map +1 -1
  70. package/dist/src/events.js +26 -1
  71. package/dist/src/events.js.map +1 -1
  72. package/dist/src/hooks/workflows.d.ts +3 -2
  73. package/dist/src/hooks/workflows.d.ts.map +1 -1
  74. package/dist/src/hooks/workflows.js +3 -2
  75. package/dist/src/hooks/workflows.js.map +1 -1
  76. package/dist/src/index.d.ts +3 -0
  77. package/dist/src/index.d.ts.map +1 -1
  78. package/dist/src/index.js +3 -0
  79. package/dist/src/index.js.map +1 -1
  80. package/dist/src/interfaces.d.ts +44 -1
  81. package/dist/src/interfaces.d.ts.map +1 -1
  82. package/dist/src/services/main.d.ts +13 -0
  83. package/dist/src/services/main.d.ts.map +1 -0
  84. package/dist/src/services/main.js +17 -0
  85. package/dist/src/services/main.js.map +1 -0
  86. package/dist/src/step-name-symbol.d.ts +2 -2
  87. package/dist/src/step-name-symbol.js +2 -2
  88. package/dist/src/stores/lucid-schema.d.ts +1 -0
  89. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  90. package/dist/src/stores/lucid-schema.js +15 -0
  91. package/dist/src/stores/lucid-schema.js.map +1 -1
  92. package/dist/src/stores/lucid.d.ts +13 -0
  93. package/dist/src/stores/lucid.d.ts.map +1 -1
  94. package/dist/src/stores/lucid.js +43 -0
  95. package/dist/src/stores/lucid.js.map +1 -1
  96. package/dist/src/testing/in-memory-state-store.d.ts +16 -0
  97. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  98. package/dist/src/testing/in-memory-state-store.js +29 -0
  99. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  100. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  101. package/dist/src/testing-kit/state-store-conformance.js +31 -0
  102. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  103. package/dist/src/workflow-als.d.ts +21 -0
  104. package/dist/src/workflow-als.d.ts.map +1 -0
  105. package/dist/src/workflow-als.js +22 -0
  106. package/dist/src/workflow-als.js.map +1 -0
  107. package/dist/src/workflow-ctx.d.ts.map +1 -1
  108. package/dist/src/workflow-ctx.js +83 -4
  109. package/dist/src/workflow-ctx.js.map +1 -1
  110. package/dist/src/workflow-discovery.d.ts +21 -15
  111. package/dist/src/workflow-discovery.d.ts.map +1 -1
  112. package/dist/src/workflow-discovery.js +17 -13
  113. package/dist/src/workflow-discovery.js.map +1 -1
  114. package/dist/src/workflow-ref.d.ts +9 -27
  115. package/dist/src/workflow-ref.d.ts.map +1 -1
  116. package/dist/src/workflow-ref.js +12 -34
  117. package/dist/src/workflow-ref.js.map +1 -1
  118. package/dist/stubs/database/migrations/create_durable_tables.stub +13 -1
  119. package/dist/stubs/make/workflow/main.stub +4 -3
  120. package/package.json +6 -1
@@ -11,6 +11,7 @@ import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
11
11
  import { SingletonGate } from './singleton-gate.js';
12
12
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
13
13
  import { TransportPool } from './transport-pool.js';
14
+ import { workflowAls } from './workflow-als.js';
14
15
  import { createWorkflowCtx, } from './workflow-ctx.js';
15
16
  import { workflowName, } from './workflow-ref.js';
16
17
  /** Union of a workflow's static tags and a run's start-time tags, de-duplicated, or undefined if none. */
@@ -592,7 +593,13 @@ export class WorkflowEngine {
592
593
  continue;
593
594
  const error = { message: 'execution timeout', code: 'execution_timeout' };
594
595
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
595
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
596
+ this.emit({
597
+ type: 'run.failed',
598
+ runId: run.id,
599
+ workflow: run.workflow,
600
+ namespace: run.namespace,
601
+ error,
602
+ });
596
603
  }
597
604
  }
598
605
  }
@@ -639,7 +646,13 @@ export class WorkflowEngine {
639
646
  };
640
647
  await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
641
648
  await this.store.releaseRunLock(run.id);
642
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
649
+ this.emit({
650
+ type: 'run.failed',
651
+ runId: run.id,
652
+ workflow: run.workflow,
653
+ namespace: run.namespace,
654
+ error,
655
+ });
643
656
  this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
644
657
  return { runId: run.id, status: 'dead', error };
645
658
  }
@@ -721,9 +734,17 @@ export class WorkflowEngine {
721
734
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
722
735
  * suspended (handed off to a timer/signal/event). The async counterpart to dispatch: pair it with
723
736
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
737
+ *
738
+ * Pass `{ terminal: true }` to wait for a strictly TERMINAL state only (completed/failed/cancelled/
739
+ * dead) — a `suspended` run (e.g. one parked on `ctx.sleep`/`ctx.waitForSignal`) does NOT resolve;
740
+ * the wait continues until the run reaches an end state. This is what `BaseWorkflow.start` needs so
741
+ * "I want the result" blocks through a suspension instead of returning early with no output. The
742
+ * default (`terminal` unset/false) resolves on any settled state, suspended included.
724
743
  */
725
744
  waitForRun(runId, opts) {
745
+ const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
726
746
  const isSettled = (s) => s !== 'pending' && s !== 'running';
747
+ const shouldResolve = opts?.terminal ? isTerminal : isSettled;
727
748
  const toResult = (run) => ({
728
749
  runId,
729
750
  status: run.status,
@@ -753,7 +774,7 @@ export class WorkflowEngine {
753
774
  };
754
775
  const check = () => {
755
776
  void this.store.getRun(runId).then((run) => {
756
- if (run && isSettled(run.status))
777
+ if (run && shouldResolve(run.status))
757
778
  finish(run);
758
779
  });
759
780
  };
@@ -797,6 +818,17 @@ export class WorkflowEngine {
797
818
  * 2. **Starts** a fresh run of every workflow registered with `onEvent: [name]`, passing the
798
819
  * payload as input. Idempotent by `evt:<id>:<workflow>` — pass `opts.id` to dedupe redeliveries
799
820
  * of the same logical event (default: a fresh uuid, so each publish triggers once).
821
+ *
822
+ * Reliable (buffered) delivery — mirrors `signalWithStart`'s reliability contract, so document it
823
+ * exactly: a publish that resumes ≥1 live waiter, OR routes into an `eventBatch` accumulator /
824
+ * starts ≥1 subscriber, is NOT buffered (fan-out stays live-only). A publish that touches NEITHER
825
+ * buffers ONE copy (via {@link StateStore.bufferEvent}) unless `opts.buffer === false`, so it isn't
826
+ * silently dropped just because nobody was listening yet. That buffered copy is consumed by the
827
+ * FIRST future `ctx.waitForEvent(name, { match })` whose match accepts its payload — point-to-point
828
+ * on redelivery, never by a later-registered onEvent subscriber. Right after buffering, this
829
+ * re-checks `listSignalWaiters` ONCE (sandwich parity with `signal`'s take → buffer → re-check) so a
830
+ * waiter that registers in the sliver between the initial miss and the buffer write is still paired
831
+ * instead of leaving both rows stranded (`waitForEvent`'s post-registration scan is the mirror half).
800
832
  */
801
833
  async publishEvent(name, payload, opts) {
802
834
  let touched = 0;
@@ -830,20 +862,45 @@ export class WorkflowEngine {
830
862
  }
831
863
  }
832
864
  }
865
+ // Nobody received it live (no waiter matched, no subscriber exists/started) — buffer ONE copy
866
+ // unless the caller opted out. `touched > 0` covers BOTH the waiter fan-out and the subscriber loop.
867
+ if (touched > 0 || opts?.buffer === false)
868
+ return touched;
869
+ const bufferedId = globalThis.crypto.randomUUID();
870
+ await this.store.bufferEvent({ name, payload, id: bufferedId, publishedAt: this.clock() });
871
+ // Re-check: a waiter may have registered in the window between the miss above and this buffer
872
+ // write (the events-side mirror of engine.signal's interleaving proof). Only the FIRST
873
+ // late-registered matching waiter matters — only one buffered copy exists.
874
+ const lateWaiters = await this.store.listSignalWaiters(eventPrefix(name));
875
+ const lateWaiter = lateWaiters.find((w) => eventMatches(payload, eventMatchOf(w.token)));
876
+ if (!lateWaiter)
877
+ return touched;
878
+ if (!(await this.store.removeBufferedEvent(bufferedId)))
879
+ return touched; // claimed elsewhere already
880
+ // `takeSignalWaiter` is safe here even though it deletes ANY row for the token: an event token
881
+ // embeds this ONE `waitForEvent` call's own `runId#seq` (see eventToken in events.ts), so no other
882
+ // registration could ever share it.
883
+ const waiter = await this.store.takeSignalWaiter(lateWaiter.token);
884
+ if (waiter) {
885
+ const settled = await this.deliverSignal(waiter, payload);
886
+ if (settled)
887
+ touched += 1;
888
+ }
889
+ // If `waiter` is null, that exact registration resolved itself some other way in the interim (most
890
+ // likely its own timeout deadline) — the buffered copy is already spent, so drop it rather than
891
+ // resuming a run a second time or re-buffering for a THIRD, unrelated waiter to pick up later.
833
892
  return touched;
834
893
  }
835
- async signal(token, payload) {
836
- const waiter = await this.store.takeSignalWaiter(token);
837
- if (!waiter) {
838
- // No one is waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
839
- // dropping it (reliable signals; the basis of `signalWithStart`).
840
- await this.store.bufferSignal(token, payload);
841
- return null;
842
- }
894
+ /**
895
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
896
+ * resume the run. Shared by {@link signal}'s direct hit and its post-buffer re-check reclaim — both
897
+ * resolve a waiter the exact same way.
898
+ */
899
+ async deliverSignal(waiter, payload) {
843
900
  await this.store.saveCheckpoint(instantCheckpoint({
844
901
  runId: waiter.runId,
845
902
  seq: waiter.seq,
846
- name: `signal:${token}`,
903
+ name: `signal:${waiter.token}`,
847
904
  kind: 'signal',
848
905
  output: payload,
849
906
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -854,6 +911,40 @@ export class WorkflowEngine {
854
911
  }));
855
912
  return this.resume(waiter.runId);
856
913
  }
914
+ /**
915
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
916
+ * the run result — or null if no run is (or ends up) waiting for it.
917
+ *
918
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check): a waiter can
919
+ * register in the sliver between this method's initial `takeSignalWaiter` miss and its
920
+ * `bufferSignal` write, and the naive old flow (buffer and return) would then leave BOTH a buffered
921
+ * payload and a registered waiter sitting in the store with nothing left to pair them — a lost wake,
922
+ * forever suspended. The interleaving proof: the waiter side does check → put → re-check, this side
923
+ * does take → buffer → re-check; whichever side's SECOND look runs last always observes the other
924
+ * side's write, and the destructive `take*` ops arbitrate so a signal is never delivered twice.
925
+ */
926
+ async signal(token, payload) {
927
+ const waiter = await this.store.takeSignalWaiter(token);
928
+ if (waiter)
929
+ return this.deliverSignal(waiter, payload);
930
+ // No one was waiting yet — buffer it so the next `waitForSignal(token)` consumes it instead of
931
+ // dropping it (reliable signals; the basis of `signalWithStart`).
932
+ await this.store.bufferSignal(token, payload);
933
+ // Re-check: a waiter may have registered in the window between the miss above and this buffer
934
+ // write (see the interleaving proof above).
935
+ const lateWaiter = await this.store.takeSignalWaiter(token);
936
+ if (!lateWaiter)
937
+ return null;
938
+ const reclaimed = await this.store.takeBufferedSignal(token);
939
+ if (!reclaimed) {
940
+ // The waiter side won the race to consume the buffer itself (its own checkpoint+resume already
941
+ // delivered the payload) in the sliver between our takeSignalWaiter and takeBufferedSignal just
942
+ // above. We've already (destructively) taken `lateWaiter` off the store, but there is nothing
943
+ // left to deliver — drop it deliberately rather than resuming the run a second time.
944
+ return null;
945
+ }
946
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
947
+ }
857
948
  /**
858
949
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
859
950
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
@@ -892,6 +983,27 @@ export class WorkflowEngine {
892
983
  notifyParent(runId, completion) {
893
984
  void this.signal(`child:${runId}`, completion).catch(() => undefined);
894
985
  }
986
+ /**
987
+ * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
988
+ * Deferred (microtask) so a fast child can't reentrantly resume a still-suspending parent. A start
989
+ * that THROWS (unregistered/unroutable workflow, input validation, singleton back-pressure, store
990
+ * failure) must NOT be swallowed: the parent is already suspended on `child:<childId>` (the waiter
991
+ * is put BEFORE start on every path), so a silent drop parks it in suspended-forever, invisibly
992
+ * re-attempting on every recovery wake — a misconfigured remote child looks exactly like a healthy
993
+ * long wait. Instead the failure is delivered to that waiter like a failed child (notifyParent):
994
+ * the parent resumes and fails loudly with the cause. For a fire-and-forget `ctx.startChild`
995
+ * (spawn — no waiter) the completion is buffered; a later join by the same id consumes it and
996
+ * correctly observes the failed start.
997
+ */
998
+ startChildDeferred(workflow, input, childId, opts) {
999
+ queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
1000
+ const message = err instanceof Error ? err.message : String(err);
1001
+ this.notifyParent(childId, {
1002
+ ok: false,
1003
+ error: `child workflow "${workflow}" failed to start: ${message}`,
1004
+ });
1005
+ }));
1006
+ }
895
1007
  /**
896
1008
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
897
1009
  * `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
@@ -929,7 +1041,13 @@ export class WorkflowEngine {
929
1041
  }
930
1042
  const error = { message: 'cancelled' };
931
1043
  await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
932
- this.emit({ type: 'run.failed', runId, workflow: run.workflow, namespace: run.namespace, error });
1044
+ this.emit({
1045
+ type: 'run.failed',
1046
+ runId,
1047
+ workflow: run.workflow,
1048
+ namespace: run.namespace,
1049
+ error,
1050
+ });
933
1051
  await this.cancelChildren(runId, opts);
934
1052
  // Notify local cancel listeners now (a worker on this pod), and broadcast so the instance/worker
935
1053
  // actually running this run learns of it and can abort cooperatively (the store already records
@@ -1266,8 +1384,22 @@ export class WorkflowEngine {
1266
1384
  void this.notifyParent(run.id, { ok: false, error: outcome.error.message });
1267
1385
  return { runId: run.id, status: 'failed', error: outcome.error };
1268
1386
  }
1387
+ // This outcome was computed by a turn that started from a possibly-stale run snapshot. If the run
1388
+ // was cancelled WHILE that turn was still executing (e.g. `ctx.all`'s failFast cancelling a
1389
+ // sibling mid-turn — plain `cancel()` writes `cancelled` directly, without waiting for the target's
1390
+ // in-flight turn to notice), this now-stale "suspended" outcome must not resurrect it: re-check the
1391
+ // CURRENT persisted status right before writing and echo it instead of clobbering a real cancel.
1392
+ const latest = await this.store.getRun(run.id);
1393
+ if (latest?.status === 'cancelled') {
1394
+ return { runId: run.id, status: 'cancelled', error: latest.error };
1395
+ }
1269
1396
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
1270
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1397
+ this.emit({
1398
+ type: 'run.suspended',
1399
+ runId: run.id,
1400
+ workflow: run.workflow,
1401
+ namespace: run.namespace,
1402
+ });
1271
1403
  return { runId: run.id, status: 'suspended' };
1272
1404
  }
1273
1405
  async runRemoteExecution(run, registered) {
@@ -1275,12 +1407,22 @@ export class WorkflowEngine {
1275
1407
  if (run.status === 'pending') {
1276
1408
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1277
1409
  run.status = 'running';
1278
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1410
+ this.emit({
1411
+ type: 'run.started',
1412
+ runId: run.id,
1413
+ workflow: run.workflow,
1414
+ namespace: run.namespace,
1415
+ });
1279
1416
  }
1280
1417
  if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1281
1418
  const wakeAt = this.singletons.retryWakeAt();
1282
1419
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1283
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1420
+ this.emit({
1421
+ type: 'run.suspended',
1422
+ runId: run.id,
1423
+ workflow: run.workflow,
1424
+ namespace: run.namespace,
1425
+ });
1284
1426
  await this.store.releaseRunLock(run.id);
1285
1427
  return { runId: run.id, status: 'suspended' };
1286
1428
  }
@@ -1295,7 +1437,13 @@ export class WorkflowEngine {
1295
1437
  stack: err instanceof Error ? err.stack : undefined,
1296
1438
  };
1297
1439
  await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
1298
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
1440
+ this.emit({
1441
+ type: 'run.failed',
1442
+ runId: run.id,
1443
+ workflow: run.workflow,
1444
+ namespace: run.namespace,
1445
+ error,
1446
+ });
1299
1447
  return { runId: run.id, status: 'failed', error };
1300
1448
  }
1301
1449
  if (decision.status === 'completed') {
@@ -1483,19 +1631,33 @@ export class WorkflowEngine {
1483
1631
  // engine.signal(name, payload) delivers it. If the signal was already delivered (buffered
1484
1632
  // before the workflow reached this point — e.g. signalWithStart), resolve it now and re-drive
1485
1633
  // on a macrotask, AFTER this turn suspends and frees the run lock (a re-entrant resume bails).
1486
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
1487
- if (buffered) {
1634
+ const deliverBuffered = async (payload) => {
1488
1635
  await this.store.saveCheckpoint(instantCheckpoint({
1489
1636
  runId: run.id,
1490
1637
  seq: cmd.seq,
1491
1638
  name: `signal:${cmd.signal}`,
1492
1639
  kind: 'signal',
1493
- output: buffered.payload,
1640
+ output: payload,
1494
1641
  }));
1495
1642
  setTimeout(() => void this.resume(run.id).catch(() => undefined), 0);
1643
+ };
1644
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
1645
+ if (buffered) {
1646
+ await deliverBuffered(buffered.payload);
1496
1647
  }
1497
1648
  else {
1649
+ // Same reorder as the in-process ctx.waitForSignal: register on a miss, then re-check once
1650
+ // more before suspending — closing the lost-wake window where a signal races in between the
1651
+ // miss above and the registration below (see the interleaving proof at engine.signal).
1498
1652
  await this.store.putSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
1653
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
1654
+ if (lateBuffered) {
1655
+ // Resolved it ourselves — remove OUR OWN row via the exact match (not
1656
+ // takeSignalWaiter(cmd.signal), which deletes ANY row for this token and could steal a
1657
+ // different run's waiter that has since claimed the same token).
1658
+ await this.store.removeSignalWaiter({ token: cmd.signal, runId: run.id, seq: cmd.seq });
1659
+ await deliverBuffered(lateBuffered.payload);
1660
+ }
1499
1661
  }
1500
1662
  }
1501
1663
  else if (cmd.kind === 'startChild') {
@@ -1516,7 +1678,7 @@ export class WorkflowEngine {
1516
1678
  parallelGroup: cmd.parallelGroup,
1517
1679
  });
1518
1680
  if (!(await this.store.getRun(childId))) {
1519
- queueMicrotask(() => void this.start(cmd.workflow, cmd.input, childId).catch(() => undefined));
1681
+ this.startChildDeferred(cmd.workflow, cmd.input, childId);
1520
1682
  }
1521
1683
  }
1522
1684
  else {
@@ -1533,14 +1695,24 @@ export class WorkflowEngine {
1533
1695
  if (run.status === 'pending') {
1534
1696
  await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
1535
1697
  run.status = 'running';
1536
- this.emit({ type: 'run.started', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1698
+ this.emit({
1699
+ type: 'run.started',
1700
+ runId: run.id,
1701
+ workflow: run.workflow,
1702
+ namespace: run.namespace,
1703
+ });
1537
1704
  }
1538
1705
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
1539
1706
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
1540
1707
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
1541
1708
  const wakeAt = this.singletons.retryWakeAt();
1542
1709
  await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
1543
- this.emit({ type: 'run.suspended', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1710
+ this.emit({
1711
+ type: 'run.suspended',
1712
+ runId: run.id,
1713
+ workflow: run.workflow,
1714
+ namespace: run.namespace,
1715
+ });
1544
1716
  await this.store.releaseRunLock(run.id);
1545
1717
  return { runId: run.id, status: 'suspended' };
1546
1718
  }
@@ -1557,7 +1729,10 @@ export class WorkflowEngine {
1557
1729
  replay.set(cp.seq, cp);
1558
1730
  const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
1559
1731
  try {
1560
- const output = await fn(ctx, run.input);
1732
+ // Establish the ambient ctx for the duration of this body turn so context-aware statics
1733
+ // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
1734
+ // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
1735
+ const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
1561
1736
  return this.settleRun(run, { kind: 'completed', output });
1562
1737
  }
1563
1738
  catch (err) {
@@ -1571,7 +1746,12 @@ export class WorkflowEngine {
1571
1746
  error: undefined,
1572
1747
  updatedAt: new Date(),
1573
1748
  });
1574
- this.emit({ type: 'run.completed', runId: run.id, workflow: run.workflow, namespace: run.namespace });
1749
+ this.emit({
1750
+ type: 'run.completed',
1751
+ runId: run.id,
1752
+ workflow: run.workflow,
1753
+ namespace: run.namespace,
1754
+ });
1575
1755
  void this.notifyParent(run.id, { ok: true, value: undefined });
1576
1756
  const nextId = nextContinuationId(run.id);
1577
1757
  queueMicrotask(() => void this.start(run.workflow, err.input, nextId).catch(() => undefined));
@@ -1589,7 +1769,13 @@ export class WorkflowEngine {
1589
1769
  }
1590
1770
  const error = { message: 'cancelled' };
1591
1771
  await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
1592
- this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, namespace: run.namespace, error });
1772
+ this.emit({
1773
+ type: 'run.failed',
1774
+ runId: run.id,
1775
+ workflow: run.workflow,
1776
+ namespace: run.namespace,
1777
+ error,
1778
+ });
1593
1779
  return { runId: run.id, status: 'cancelled', error };
1594
1780
  }
1595
1781
  return this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
@@ -1711,7 +1897,7 @@ export class WorkflowEngine {
1711
1897
  callRemote: (runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission),
1712
1898
  // Defer so a fast child can't reentrantly resume a still-running parent.
1713
1899
  startChild: (workflow, input, id, priority) => {
1714
- queueMicrotask(() => void this.start(workflow, input, id, { priority }).catch(() => undefined));
1900
+ this.startChildDeferred(workflow, input, id, { priority });
1715
1901
  },
1716
1902
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
1717
1903
  upsertSearchAttributes: async (runId, attrs) => {