@dudousxd/nestjs-durable-core 0.5.0 → 0.7.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 (47) hide show
  1. package/dist/backoff.d.ts +5 -0
  2. package/dist/backoff.d.ts.map +1 -0
  3. package/dist/backoff.js +14 -0
  4. package/dist/backoff.js.map +1 -0
  5. package/dist/checkpoints.d.ts +17 -0
  6. package/dist/checkpoints.d.ts.map +1 -0
  7. package/dist/checkpoints.js +28 -0
  8. package/dist/checkpoints.js.map +1 -0
  9. package/dist/completion.d.ts +15 -0
  10. package/dist/completion.d.ts.map +1 -0
  11. package/dist/completion.js +13 -0
  12. package/dist/completion.js.map +1 -0
  13. package/dist/engine.d.ts +117 -10
  14. package/dist/engine.d.ts.map +1 -1
  15. package/dist/engine.js +287 -276
  16. package/dist/engine.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/interfaces.d.ts +113 -17
  22. package/dist/interfaces.d.ts.map +1 -1
  23. package/dist/protocol.d.ts +3 -0
  24. package/dist/protocol.d.ts.map +1 -1
  25. package/dist/protocol.js +13 -1
  26. package/dist/protocol.js.map +1 -1
  27. package/dist/queue.d.ts +48 -0
  28. package/dist/queue.d.ts.map +1 -0
  29. package/dist/queue.js +45 -0
  30. package/dist/queue.js.map +1 -0
  31. package/dist/scheduler.d.ts +22 -7
  32. package/dist/scheduler.d.ts.map +1 -1
  33. package/dist/scheduler.js +40 -6
  34. package/dist/scheduler.js.map +1 -1
  35. package/dist/testing/in-memory-transport.d.ts +6 -4
  36. package/dist/testing/in-memory-transport.d.ts.map +1 -1
  37. package/dist/testing/in-memory-transport.js +4 -2
  38. package/dist/testing/in-memory-transport.js.map +1 -1
  39. package/dist/transport-pool.d.ts +23 -0
  40. package/dist/transport-pool.d.ts.map +1 -0
  41. package/dist/transport-pool.js +54 -0
  42. package/dist/transport-pool.js.map +1 -0
  43. package/dist/workflow-ctx.d.ts +46 -0
  44. package/dist/workflow-ctx.d.ts.map +1 -0
  45. package/dist/workflow-ctx.js +260 -0
  46. package/dist/workflow-ctx.js.map +1 -0
  47. package/package.json +8 -1
package/dist/engine.js CHANGED
@@ -1,21 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RemoteStepError = exports.WorkflowEngine = void 0;
4
- const duration_1 = require("./duration");
4
+ const backoff_1 = require("./backoff");
5
+ const checkpoints_1 = require("./checkpoints");
5
6
  const errors_1 = require("./errors");
6
7
  const protocol_1 = require("./protocol");
7
- const step_logger_1 = require("./step-logger");
8
- /** Deterministic signal token a breakpoint suspends on — derived from its logical position. */
9
- const breakpointToken = (runId, seq) => `bp:${runId}:${seq}`;
10
- /** Delay in ms before the next retry of a local step, per its `StepOptions` backoff config. */
11
- function backoffDelay(attempt, options) {
12
- const base = options?.backoffMs ?? 0;
13
- if (base <= 0)
14
- return 0;
15
- const raw = options?.backoff === 'exp' ? base * 2 ** (attempt - 1) : base;
16
- const capped = options?.backoffMaxMs ? Math.min(raw, options.backoffMaxMs) : raw;
17
- return options?.jitter ? Math.round(capped * (0.5 + Math.random() * 0.5)) : capped;
18
- }
8
+ const queue_1 = require("./queue");
9
+ const transport_pool_1 = require("./transport-pool");
10
+ const workflow_ctx_1 = require("./workflow-ctx");
19
11
  /** A breakpoint checkpoint's `name` is `breakpoint` (or `breakpoint:<label>`). This name — not the
20
12
  * reused `signal` kind — is the explicit marker the dashboard and `continue()` detect it by. */
21
13
  const BREAKPOINT = 'breakpoint';
@@ -29,14 +21,6 @@ function isNewerVersion(a, b) {
29
21
  return na > nb;
30
22
  return a.localeCompare(b, undefined, { numeric: true }) > 0;
31
23
  }
32
- /** Read a `Completion` from a signal payload: return the value, or throw a FatalError if it failed. */
33
- function unwrapCompletion(payload, label) {
34
- const c = payload;
35
- if (c && typeof c === 'object' && 'ok' in c && c.ok === false) {
36
- throw new errors_1.FatalError(`${label} failed: ${c.error}`);
37
- }
38
- return c?.value;
39
- }
40
24
  /**
41
25
  * The orchestrator. Owns workflow state and replays runs deterministically: each step's
42
26
  * result is checkpointed, so on resume a completed step returns its saved output instead of
@@ -45,10 +29,16 @@ function unwrapCompletion(payload, label) {
45
29
  */
46
30
  class WorkflowEngine {
47
31
  store;
48
- transport;
32
+ /** Ordered transport pool (dispatch + failover). Empty = no remote steps. */
33
+ pool;
34
+ controlPlane;
49
35
  clock;
50
36
  instanceId;
51
37
  leaseMs;
38
+ maxRecoveryAttempts;
39
+ webhookUrl;
40
+ traceparent;
41
+ compensationRetries;
52
42
  /** Every registered workflow, keyed by `name@version` — so old versions stay runnable. */
53
43
  workflows = new Map();
54
44
  /** The newest registered version per workflow name — used to `start` new runs. */
@@ -60,16 +50,31 @@ class WorkflowEngine {
60
50
  listeners = new Set();
61
51
  /** Callbacks notified (on any instance) when a run is cancelled — for cooperative cancellation. */
62
52
  cancelListeners = new Set();
53
+ /** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
54
+ deadListeners = new Set();
55
+ /** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
56
+ updateValidators = new Map();
57
+ /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
58
+ cancelRequested = new Set();
59
+ /** Flow-control queues for remote steps, keyed by name (see {@link registerQueue}). */
60
+ queues = new Map();
61
+ /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
62
+ stepQueue = new Map();
63
63
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
64
64
  inflight = new Set();
65
65
  draining = false;
66
66
  constructor(deps) {
67
67
  this.store = deps.store;
68
- this.transport = deps.transport;
68
+ this.pool = new transport_pool_1.TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
69
+ this.controlPlane = deps.controlPlane;
69
70
  this.clock = deps.clock ?? Date.now;
70
71
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
71
72
  this.leaseMs = deps.leaseMs ?? 30_000;
72
- this.transport?.onResult(async (result) => {
73
+ this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
74
+ this.webhookUrl = deps.webhookUrl;
75
+ this.traceparent = deps.traceparent;
76
+ this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
77
+ this.pool.bind(async (result) => {
73
78
  // In-memory path (a `timeoutMs` step awaiting on THIS instance): resolve its pending promise.
74
79
  const waiter = this.pending.get(result.stepId);
75
80
  if (waiter) {
@@ -89,15 +94,15 @@ class WorkflowEngine {
89
94
  // Durable path: no in-memory waiter (the step suspended the run, possibly on another
90
95
  // instance) → complete the checkpoint and resume the run here.
91
96
  await this.completeRemoteResult(result);
92
- });
97
+ },
93
98
  // A heartbeat for an in-flight long step resets its liveness window (see callRemote).
94
- this.transport?.onHeartbeat(async (beat) => {
99
+ async (beat) => {
95
100
  this.heartbeatResets.get(beat.stepId)?.();
96
101
  });
97
102
  // Control plane: re-broadcast lifecycle events from OTHER instances to this instance's
98
103
  // subscribers (cross-pod live-tail), and act on cancellations issued elsewhere. A broker may
99
104
  // echo our own publish back — ignore those (we already handled them locally) to avoid duplicates.
100
- this.transport?.onControl?.((msg) => {
105
+ this.controlPlane?.onControl((msg) => {
101
106
  if (msg.from === this.instanceId)
102
107
  return;
103
108
  if (msg.kind === 'event') {
@@ -132,6 +137,14 @@ class WorkflowEngine {
132
137
  if (!current || isNewerVersion(version, current.version))
133
138
  this.latest.set(name, registered);
134
139
  }
140
+ /**
141
+ * Register a flow-control queue referenced by `ctx.call(step, input, { queue })`. Caps concurrent
142
+ * in-flight steps and/or the admission rate; blocked calls re-suspend and retry, so the limit is
143
+ * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
144
+ */
145
+ registerQueue(config) {
146
+ this.queues.set(config.name, new queue_1.QueueController(config, this.clock));
147
+ }
135
148
  /** Subscribe to lifecycle events. Returns an unsubscribe function. */
136
149
  subscribe(listener) {
137
150
  this.listeners.add(listener);
@@ -146,13 +159,34 @@ class WorkflowEngine {
146
159
  this.cancelListeners.add(listener);
147
160
  return () => this.cancelListeners.delete(listener);
148
161
  }
162
+ /**
163
+ * Be notified when a run is **dead-lettered** — moved to `dead` after exceeding
164
+ * `maxRecoveryAttempts`. The listener receives the dead run (status `dead`, with its error), so a
165
+ * DLQ handler can do something other than just leaving it parked: alert, push to a real queue, or
166
+ * start a dead-letter workflow (e.g. `engine.onDead((run) => engine.start('pipeline-dlq', run, ...))`).
167
+ * Returns an unsubscribe function.
168
+ */
169
+ onDead(listener) {
170
+ this.deadListeners.add(listener);
171
+ return () => this.deadListeners.delete(listener);
172
+ }
173
+ notifyDead(run) {
174
+ for (const fn of this.deadListeners) {
175
+ try {
176
+ fn(run);
177
+ }
178
+ catch {
179
+ /* a dead-letter handler must not break recovery */
180
+ }
181
+ }
182
+ }
149
183
  /** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
150
184
  * control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
151
185
  emit(event) {
152
186
  const full = { ...event, at: new Date() };
153
187
  this.deliver(full);
154
- if (this.transport?.publishControl) {
155
- void this.transport
188
+ if (this.controlPlane) {
189
+ void this.controlPlane
156
190
  .publishControl({ kind: 'event', event: full, from: this.instanceId })
157
191
  .catch(() => {
158
192
  // control-plane delivery is best-effort observability; never break execution
@@ -202,7 +236,7 @@ class WorkflowEngine {
202
236
  // A definitively-finished run must not be re-executed (e.g. a worker result landing after the
203
237
  // run was cancelled, or a duplicate resume) — that would replay the body and clobber the
204
238
  // terminal state. `failed` is intentionally NOT terminal here: retry resumes a failed run.
205
- if (run.status === 'cancelled' || run.status === 'completed') {
239
+ if (run.status === 'cancelled' || run.status === 'completed' || run.status === 'dead') {
206
240
  return { runId, status: run.status, output: run.output, error: run.error };
207
241
  }
208
242
  // Pin to the version the run STARTED on — replay is positional, so running a changed
@@ -238,8 +272,30 @@ class WorkflowEngine {
238
272
  * Resume every run left incomplete by a crash or deploy. Called on boot. Completed steps
239
273
  * replay from their checkpoints, so only the work that had not finished runs again.
240
274
  */
241
- async recoverIncomplete() {
242
- return this.resumeLeased(await this.store.listIncompleteRuns());
275
+ async recoverIncomplete(nowMs = this.clock()) {
276
+ return this.resumeLeased(await this.store.listIncompleteRuns(), nowMs, (run) => this.countRecovery(run));
277
+ }
278
+ /**
279
+ * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
280
+ * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
281
+ * result to skip the resume, or `undefined` to proceed.
282
+ */
283
+ async countRecovery(run) {
284
+ const attempts = (run.recoveryAttempts ?? 0) + 1;
285
+ if (this.maxRecoveryAttempts != null && attempts > this.maxRecoveryAttempts) {
286
+ const error = {
287
+ message: `run exceeded maxRecoveryAttempts (${this.maxRecoveryAttempts}) — moved to dead-letter`,
288
+ code: 'max_recovery_attempts',
289
+ };
290
+ await this.store.updateRun(run.id, { status: 'dead', error, updatedAt: new Date() });
291
+ await this.store.releaseRunLock(run.id);
292
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
293
+ this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
294
+ return { runId: run.id, status: 'dead', error };
295
+ }
296
+ // Count BEFORE resuming, so a crash mid-resume still advances the counter.
297
+ await this.store.updateRun(run.id, { recoveryAttempts: attempts, updatedAt: new Date() });
298
+ return undefined;
243
299
  }
244
300
  /**
245
301
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
@@ -252,14 +308,17 @@ class WorkflowEngine {
252
308
  * Resume each run only if this instance can acquire its recovery lease — so when several
253
309
  * replicas recover or poll at once, each run is picked up by exactly one of them.
254
310
  */
255
- async resumeLeased(runs, nowMs = this.clock()) {
311
+ async resumeLeased(runs, nowMs = this.clock(), onLocked) {
256
312
  if (this.draining)
257
313
  return []; // shutting down — don't pick up new runs
258
314
  const results = [];
259
315
  for (const run of runs) {
260
316
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
261
- if (acquired)
262
- results.push(await this.resume(run.id));
317
+ if (!acquired)
318
+ continue;
319
+ // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
320
+ const settled = onLocked ? await onLocked(run) : undefined;
321
+ results.push(settled ?? (await this.resume(run.id)));
263
322
  }
264
323
  return results;
265
324
  }
@@ -271,20 +330,13 @@ class WorkflowEngine {
271
330
  const waiter = await this.store.takeSignalWaiter(token);
272
331
  if (!waiter)
273
332
  return null;
274
- const at = new Date();
275
- await this.store.saveCheckpoint({
333
+ await this.store.saveCheckpoint((0, checkpoints_1.instantCheckpoint)({
276
334
  runId: waiter.runId,
277
335
  seq: waiter.seq,
278
336
  name: `signal:${token}`,
279
337
  kind: 'signal',
280
- stepId: (0, protocol_1.stepId)(waiter.runId, waiter.seq),
281
- status: 'completed',
282
338
  output: payload,
283
- attempts: 1,
284
- enqueuedAt: at,
285
- startedAt: at,
286
- finishedAt: at,
287
- });
339
+ }));
288
340
  return this.resume(waiter.runId);
289
341
  }
290
342
  /**
@@ -310,11 +362,29 @@ class WorkflowEngine {
310
362
  notifyParent(runId, completion) {
311
363
  void this.signal(`child:${runId}`, completion).catch(() => undefined);
312
364
  }
313
- /** Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. */
314
- async cancel(runId) {
365
+ /**
366
+ * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
367
+ * `{ compensate: true }` to undo the saga first: the suspended run is resumed so its completed
368
+ * steps' compensations run in reverse (visible as `compensate:<step>` events), then it's marked
369
+ * cancelled. Without it, cancellation is immediate (no undo).
370
+ */
371
+ async cancel(runId, opts) {
315
372
  const run = await this.store.getRun(runId);
316
373
  if (!run)
317
374
  return null;
375
+ // Compensating cancel: resume the run with a cancellation pending. Replay re-registers the
376
+ // saga, and at the run's suspension point execute() runs the undo and marks it cancelled.
377
+ if (opts?.compensate && (run.status === 'suspended' || run.status === 'running')) {
378
+ this.cancelRequested.add(runId);
379
+ const result = await this.resume(runId);
380
+ this.notifyCancelled(runId);
381
+ if (this.controlPlane) {
382
+ void this.controlPlane
383
+ .publishControl({ kind: 'cancel', runId, from: this.instanceId })
384
+ .catch(() => undefined);
385
+ }
386
+ return result;
387
+ }
318
388
  const error = { message: 'cancelled' };
319
389
  await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
320
390
  this.emit({ type: 'run.failed', runId, workflow: run.workflow, error });
@@ -322,8 +392,8 @@ class WorkflowEngine {
322
392
  // actually running this run learns of it and can abort cooperatively (the store already records
323
393
  // `cancelled`, but a busy worker won't re-read it).
324
394
  this.notifyCancelled(runId);
325
- if (this.transport?.publishControl) {
326
- void this.transport
395
+ if (this.controlPlane) {
396
+ void this.controlPlane
327
397
  .publishControl({ kind: 'cancel', runId, from: this.instanceId })
328
398
  .catch(() => undefined);
329
399
  }
@@ -390,7 +460,7 @@ class WorkflowEngine {
390
460
  async execute(run, fn) {
391
461
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
392
462
  const compensations = [];
393
- const ctx = this.makeCtx(run.id, compensations);
463
+ const ctx = (0, workflow_ctx_1.createWorkflowCtx)(this.ctxHost, run.id, compensations);
394
464
  try {
395
465
  const output = await fn(ctx, run.input);
396
466
  // Clear any error from an earlier failed-then-retried attempt: a completed run is a success
@@ -408,6 +478,20 @@ class WorkflowEngine {
408
478
  }
409
479
  catch (err) {
410
480
  if (err instanceof errors_1.WorkflowSuspended) {
481
+ // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
482
+ // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
483
+ if (this.cancelRequested.has(run.id)) {
484
+ this.cancelRequested.delete(run.id);
485
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
486
+ const comp = compensations[i];
487
+ if (comp)
488
+ await this.runCompensation(run, comp);
489
+ }
490
+ const error = { message: 'cancelled' };
491
+ await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
492
+ this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
493
+ return { runId: run.id, status: 'cancelled', error };
494
+ }
411
495
  await this.store.updateRun(run.id, {
412
496
  status: 'suspended',
413
497
  wakeAt: err.wakeAt,
@@ -420,15 +504,15 @@ class WorkflowEngine {
420
504
  message: err instanceof Error ? err.message : String(err),
421
505
  stack: err instanceof Error ? err.stack : undefined,
422
506
  };
423
- // Saga: undo completed steps in reverse. A compensation that throws is logged-and-skipped so
424
- // one bad undo can't strand the rest. (Compensations are best-effort and should be idempotent.)
507
+ // Saga: undo completed steps in reverse, each retried up to `compensationRetries`. Outcomes
508
+ // are emitted as `compensate:<step>` step events so a stranded undo is VISIBLE (not silently
509
+ // swallowed) in the dashboard/telescope; a failing one is still skipped so it can't mask the
510
+ // original failure or strand the rest. (Compensations should be idempotent.)
425
511
  for (let i = compensations.length - 1; i >= 0; i -= 1) {
426
- try {
427
- await compensations[i]?.();
428
- }
429
- catch {
430
- // a failing compensation must not mask the original failure
431
- }
512
+ const comp = compensations[i];
513
+ if (!comp)
514
+ continue;
515
+ await this.runCompensation(run, comp);
432
516
  }
433
517
  await this.store.updateRun(run.id, { status: 'failed', error, updatedAt: new Date() });
434
518
  this.emit({ type: 'run.failed', runId: run.id, workflow: run.workflow, error });
@@ -441,219 +525,59 @@ class WorkflowEngine {
441
525
  await this.store.releaseRunLock(run.id);
442
526
  }
443
527
  }
444
- makeCtx(runId, compensations) {
445
- let seq = -1;
446
- const nextSeq = () => {
447
- seq += 1;
448
- return seq;
449
- };
450
- const store = this.store;
451
- // Each ctx primitive is a named closure over `nextSeq` (the per-run logical position counter)
452
- // and `compensations` (the saga undo stack). They're defined up front so `task`/`child` can
453
- // compose `step`/`waitForSignal` directly — no post-construction mutation, no cast.
454
- const step = async (name, fn, options) => {
455
- const current = nextSeq();
456
- const existing = await store.getCheckpoint(runId, current);
457
- if (existing && existing.name !== name) {
458
- throw new errors_1.NonDeterminismError(runId, current, name, existing.name);
459
- }
460
- if (existing && existing.status === 'completed') {
461
- // Register the compensation on replay too, so a saga undoes ALL completed steps — even
462
- // those done in an earlier (since-suspended) pass — not just the ones run this pass.
463
- if (options?.compensate)
464
- compensations.push(options.compensate);
465
- return existing.output;
528
+ /**
529
+ * Run one saga compensation, retried up to `compensationRetries`, emitting a `compensate:<step>`
530
+ * step event for its outcome so a stranded undo is visible. Never throws — a permanently-failing
531
+ * compensation is skipped so it can't mask the original failure.
532
+ */
533
+ async runCompensation(run, comp) {
534
+ const name = `compensate:${comp.name}`;
535
+ for (let attempt = 1; attempt <= this.compensationRetries; attempt += 1) {
536
+ const startedAt = Date.now();
537
+ try {
538
+ await comp.fn();
539
+ this.emit({
540
+ type: 'step.completed',
541
+ runId: run.id,
542
+ workflow: run.workflow,
543
+ name,
544
+ kind: 'local',
545
+ durationMs: Date.now() - startedAt,
546
+ });
547
+ return;
466
548
  }
467
- const maxAttempts = Math.max(1, options?.retries ?? 1);
468
- const startedAt = new Date();
469
- for (let attempt = 1;; attempt += 1) {
470
- // Events are scoped per attempt — a retry starts a clean log, so the checkpoint reflects
471
- // only the attempt that actually completed (or the final failing one).
472
- const events = [];
473
- try {
474
- const output = await fn((0, step_logger_1.createStepLogger)(events, this.clock));
475
- await this.completeStep({
476
- runId,
477
- seq: current,
549
+ catch (err) {
550
+ if (attempt >= this.compensationRetries) {
551
+ this.emit({
552
+ type: 'step.failed',
553
+ runId: run.id,
554
+ workflow: run.workflow,
478
555
  name,
479
556
  kind: 'local',
480
- output,
481
- events,
482
- attempts: attempt,
483
- enqueuedAt: startedAt,
484
- startedAt,
557
+ error: { message: err instanceof Error ? err.message : String(err) },
558
+ durationMs: Date.now() - startedAt,
485
559
  });
486
- if (options?.compensate)
487
- compensations.push(options.compensate);
488
- return output;
489
- }
490
- catch (err) {
491
- if (err instanceof errors_1.FatalError || attempt >= maxAttempts) {
492
- await this.failStep({
493
- runId,
494
- seq: current,
495
- name,
496
- kind: 'local',
497
- error: {
498
- message: err instanceof Error ? err.message : String(err),
499
- stack: err instanceof Error ? err.stack : undefined,
500
- },
501
- events,
502
- attempts: attempt,
503
- enqueuedAt: startedAt,
504
- startedAt,
505
- });
506
- throw err;
507
- }
508
- // Wait out the backoff before the next attempt (no-op when backoffMs is unset).
509
- const wait = backoffDelay(attempt, options);
510
- if (wait > 0)
511
- await new Promise((resolve) => setTimeout(resolve, wait));
512
560
  }
513
561
  }
514
- };
515
- const sleep = async (duration) => {
516
- const current = nextSeq();
517
- const now = this.clock();
518
- const existing = await store.getCheckpoint(runId, current);
519
- if (existing) {
520
- // Timer already recorded: resume if due, otherwise re-suspend cheaply.
521
- if (now >= (existing.wakeAt ?? 0))
522
- return;
523
- throw new errors_1.WorkflowSuspended(existing.wakeAt ?? now);
524
- }
525
- const wakeAt = now + (0, duration_1.parseDuration)(duration);
526
- await this.recordTimer(runId, current, 'sleep', wakeAt);
527
- throw new errors_1.WorkflowSuspended(wakeAt);
528
- };
529
- // NOTE (determinism): a bounded wait consumes TWO logical positions (deadline + wait), an
530
- // unbounded one consumes ONE. So adding or removing `{ timeoutMs }` on an existing
531
- // `waitForSignal` shifts the seq of every later step — treat it as a workflow-version change
532
- // (register a new @Workflow version) for runs already in flight.
533
- const waitForSignal = async (token, opts) => {
534
- if (opts?.timeoutMs == null) {
535
- const current = nextSeq();
536
- const existing = await store.getCheckpoint(runId, current);
537
- if (existing && existing.status === 'completed')
538
- return existing.output;
539
- await store.putSignalWaiter({ token, runId, seq: current });
540
- throw new errors_1.WorkflowSuspended();
541
- }
542
- const timeoutMs = opts.timeoutMs;
543
- const deadlineSeq = nextSeq();
544
- const waitSeq = nextSeq();
545
- // The deadline is recorded durably as a timer checkpoint so replay knows it; the run also gets
546
- // a run-level wakeAt (via WorkflowSuspended) so the timer poller resumes it at the deadline.
547
- const recorded = await store.getCheckpoint(runId, deadlineSeq);
548
- const deadline = recorded?.wakeAt ?? this.clock() + timeoutMs;
549
- if (!recorded)
550
- await this.recordTimer(runId, deadlineSeq, `timeout:${token}`, deadline);
551
- const waited = await store.getCheckpoint(runId, waitSeq);
552
- if (waited && waited.status === 'completed')
553
- return waited.output;
554
- if (this.clock() >= deadline) {
555
- await store.takeSignalWaiter(token).catch(() => undefined);
556
- throw new errors_1.SignalTimeoutError(token, timeoutMs);
557
- }
558
- await store.putSignalWaiter({ token, runId, seq: waitSeq });
559
- throw new errors_1.WorkflowSuspended(deadline);
560
- };
561
- // An external task = a checkpointed dispatch + a wait for its async-completion `Completion`
562
- // (delivered by engine.completeTask/failTask). The whole "fire at a foreign system, suspend,
563
- // resume when it reports back" pattern as one call.
564
- const task = async (name, dispatch, options) => {
565
- await step(`task:dispatch:${name}`, dispatch, options);
566
- return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
567
- };
568
- // Child workflow: start it once, then suspend on a `child:<id>` waiter the child signals on its
569
- // terminal state (see notifyParent). The start is deferred to a microtask so it runs AFTER this
570
- // run suspends — a fast child can't reentrantly resume a parent that's still running.
571
- // A breakpoint = a visible `pending` checkpoint + a signal waiter the dashboard resumes via
572
- // `engine.continue`. Reuses the signal machinery (kind 'signal'), so resume overwrites the
573
- // pending checkpoint with a completed one and the run replays past it.
574
- const breakpoint = async (label) => {
575
- const current = nextSeq();
576
- const existing = await store.getCheckpoint(runId, current);
577
- if (existing && existing.status === 'completed')
578
- return;
579
- if (!existing) {
580
- await this.recordBreakpoint(runId, current, label);
581
- await store.putSignalWaiter({
582
- token: breakpointToken(runId, current),
583
- runId,
584
- seq: current,
585
- });
586
- }
587
- throw new errors_1.WorkflowSuspended();
588
- };
589
- const child = async (workflow, input, childId) => {
590
- const current = nextSeq();
591
- const id = childId ?? `${runId}.child.${current}`;
592
- const existing = await store.getCheckpoint(runId, current);
593
- if (existing && existing.status === 'completed') {
594
- return unwrapCompletion(existing.output, `child "${id}"`);
595
- }
596
- await store.putSignalWaiter({ token: `child:${id}`, runId, seq: current });
597
- if (!(await store.getRun(id))) {
598
- queueMicrotask(() => {
599
- void this.start(workflow, input, id).catch(() => undefined);
600
- });
601
- }
602
- throw new errors_1.WorkflowSuspended();
603
- };
604
- // Deterministic non-deterministic sources: each is a checkpointed local step, so the value is
605
- // captured on the first run and replayed verbatim afterwards (a raw Date.now()/Math.random()
606
- // inside a workflow would differ across replays and corrupt the run).
607
- const now = () => step('now', async () => this.clock());
608
- const random = () => step('random', async () => Math.random());
609
- const uuid = () => step('uuid', async () => globalThis.crypto.randomUUID());
562
+ }
563
+ }
564
+ /** The seam handed to {@link createWorkflowCtx}: the authoring API reaches durability + lifecycle
565
+ * (checkpointing, dispatch, child start) through this, so the ctx primitives live in their own
566
+ * module and the engine stays the orchestrator. */
567
+ get ctxHost() {
610
568
  return {
611
- runId,
612
- step,
613
- sleep,
614
- waitForSignal,
615
- task,
616
- child,
617
- breakpoint,
618
- now,
619
- random,
620
- uuid,
621
- call: (remote, input) => this.callRemote(runId, nextSeq(), remote, input),
569
+ store: this.store,
570
+ clock: this.clock,
571
+ webhookUrl: this.webhookUrl,
572
+ completeStep: (s) => this.completeStep(s),
573
+ failStep: (s) => this.failStep(s),
574
+ callRemote: (runId, seq, step, input, queue, transport) => this.callRemote(runId, seq, step, input, queue, transport),
575
+ // Defer so a fast child can't reentrantly resume a still-running parent.
576
+ startChild: (workflow, input, id) => {
577
+ queueMicrotask(() => void this.start(workflow, input, id).catch(() => undefined));
578
+ },
622
579
  };
623
580
  }
624
- /** Persist a completed timer checkpoint (a durable sleep / signal deadline) at a logical position. */
625
- async recordTimer(runId, seq, name, wakeAt) {
626
- const at = new Date();
627
- await this.store.saveCheckpoint({
628
- runId,
629
- seq,
630
- name,
631
- kind: 'sleep',
632
- stepId: (0, protocol_1.stepId)(runId, seq),
633
- status: 'completed',
634
- wakeAt,
635
- attempts: 1,
636
- enqueuedAt: at,
637
- startedAt: at,
638
- finishedAt: at,
639
- });
640
- }
641
- /** Persist a `pending` checkpoint marking a breakpoint, so it's visible (and resumable) in the UI. */
642
- async recordBreakpoint(runId, seq, label) {
643
- const at = new Date();
644
- await this.store.saveCheckpoint({
645
- runId,
646
- seq,
647
- name: label ? `${BREAKPOINT}:${label}` : BREAKPOINT,
648
- kind: 'signal',
649
- stepId: (0, protocol_1.stepId)(runId, seq),
650
- status: 'pending',
651
- attempts: 1,
652
- enqueuedAt: at,
653
- startedAt: at,
654
- finishedAt: at,
655
- });
656
- }
657
581
  /**
658
582
  * Resume a run paused at a {@link WorkflowCtx.breakpoint} (e.g. the dashboard "continue" button).
659
583
  * Finds the run's pending breakpoint checkpoint and signals it. Returns null if the run isn't
@@ -664,29 +588,103 @@ class WorkflowEngine {
664
588
  const bp = checkpoints.find(isBreakpoint);
665
589
  if (!bp)
666
590
  return null;
667
- return this.signal(breakpointToken(runId, bp.seq), undefined);
591
+ return this.signal((0, protocol_1.breakpointToken)(runId, bp.seq), undefined);
668
592
  }
669
- async callRemote(runId, seq, step, input) {
593
+ /**
594
+ * Read the latest value a run published for `key` via {@link WorkflowCtx.setEvent} — a
595
+ * side-effect-free query of a live (or finished) run's state. Returns `undefined` if the run
596
+ * never published that key. The suspend-model counterpart of a Temporal query.
597
+ */
598
+ async getEvent(runId, key) {
599
+ const name = `event:${key}`;
600
+ const checkpoints = await this.store.listCheckpoints(runId);
601
+ // listCheckpoints is ordered by seq ascending, so the last match is the most recent value.
602
+ let latest;
603
+ for (const cp of checkpoints)
604
+ if (cp.name === name)
605
+ latest = cp.output;
606
+ return latest;
607
+ }
608
+ /**
609
+ * Register a validator gating `engine.update(runId, name, …)` for runs of `workflow`. The
610
+ * validator runs BEFORE the update is delivered, so a rejection leaves the run untouched. One
611
+ * validator per (workflow, update name); registering again replaces it.
612
+ */
613
+ registerUpdateValidator(workflow, name, validate) {
614
+ this.updateValidators.set(`${workflow}:${name}`, validate);
615
+ }
616
+ /**
617
+ * Deliver a validated update to the run waiting at `ctx.onUpdate(name)`. Runs the registered
618
+ * validator (if any) first: on rejection returns `{ accepted: false, reason }` without disturbing
619
+ * the run; otherwise delivers `arg` and resumes, returning `{ accepted: true, run }` (`run` is null
620
+ * if nothing was waiting — a too-early or duplicate update).
621
+ */
622
+ async update(runId, name, arg) {
623
+ const run = await this.store.getRun(runId);
624
+ if (!run)
625
+ return { accepted: false, reason: `run ${runId} not found` };
626
+ const validate = this.updateValidators.get(`${run.workflow}:${name}`);
627
+ if (validate) {
628
+ try {
629
+ const reason = await validate(arg);
630
+ if (typeof reason === 'string' && reason.length > 0)
631
+ return { accepted: false, reason };
632
+ }
633
+ catch (err) {
634
+ return { accepted: false, reason: err instanceof Error ? err.message : String(err) };
635
+ }
636
+ }
637
+ const result = await this.signal(`update:${runId}:${name}`, arg);
638
+ return { accepted: true, run: result };
639
+ }
640
+ async callRemote(runId, seq, step, input, queue, transport) {
670
641
  const existing = await this.store.getCheckpoint(runId, seq);
671
642
  if (existing && existing.name !== step.name) {
672
643
  throw new errors_1.NonDeterminismError(runId, seq, step.name, existing.name);
673
644
  }
674
645
  if (existing?.status === 'completed')
675
646
  return existing.output;
676
- if (existing?.status === 'failed')
677
- throw new RemoteStepError(existing.error);
678
- if (!this.transport)
647
+ if (this.pool.size === 0)
679
648
  throw new Error('remote steps require a Transport');
680
649
  // A step with a liveness `timeoutMs` keeps the in-memory await + heartbeat path (re-dispatch a
681
650
  // presumed-dead worker). Without one, the call SUSPENDS DURABLY: dispatch, persist a `pending`
682
651
  // checkpoint, and let the result resume the run on whichever instance receives it — so a worker
683
652
  // pod can scale down or crash mid-step without losing the run or re-running completed work.
684
653
  if (step.timeoutMs)
685
- return this.callRemoteInMemory(runId, seq, step, input);
654
+ return this.callRemoteInMemory(runId, seq, step, input, transport);
686
655
  if (existing?.status === 'pending')
687
656
  throw new errors_1.WorkflowSuspended(); // dispatched; keep waiting
688
- const validInput = step.input.parse(input);
657
+ // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
658
+ // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
659
+ // The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
660
+ // first time we see it, so it's stable across replays and survives a crash.
661
+ let attempt = 1;
662
+ if (existing?.status === 'failed') {
663
+ const maxAttempts = Math.max(1, step.retries ?? 1);
664
+ const retryable = existing.error?.retryable !== false;
665
+ if (!retryable || existing.attempts >= maxAttempts)
666
+ throw new RemoteStepError(existing.error);
667
+ if (existing.wakeAt == null) {
668
+ const wakeAt = this.clock() + (0, backoff_1.backoffDelay)(existing.attempts, step);
669
+ await this.store.saveCheckpoint({ ...existing, wakeAt });
670
+ throw new errors_1.WorkflowSuspended(wakeAt);
671
+ }
672
+ if (this.clock() < existing.wakeAt)
673
+ throw new errors_1.WorkflowSuspended(existing.wakeAt);
674
+ attempt = existing.attempts + 1;
675
+ }
689
676
  const id = (0, protocol_1.stepId)(runId, seq);
677
+ // Flow control: a queued call that can't be admitted (concurrency/rate) does NOT dispatch — the
678
+ // run re-suspends with the queue's retry time and the timer poller re-tries admission later, so
679
+ // the limit is durable. The admitted slot is released when the result lands (completeRemoteResult).
680
+ const controller = queue ? this.queues.get(queue) : undefined;
681
+ if (controller) {
682
+ const admission = controller.tryAdmit();
683
+ if (!admission.ok)
684
+ throw new errors_1.WorkflowSuspended(admission.retryAt);
685
+ this.stepQueue.set(id, queue);
686
+ }
687
+ const validInput = step.input.parse(input);
690
688
  const enqueuedAt = new Date();
691
689
  // Persist the pending checkpoint BEFORE dispatching, so a fast result always finds it to complete.
692
690
  await this.store.saveCheckpoint({
@@ -697,21 +695,22 @@ class WorkflowEngine {
697
695
  stepId: id,
698
696
  status: 'pending',
699
697
  input: validInput,
700
- attempts: 1,
698
+ attempts: attempt,
701
699
  workerGroup: step.group,
702
700
  enqueuedAt,
703
701
  startedAt: enqueuedAt, // placeholders until the worker result lands
704
702
  finishedAt: enqueuedAt,
705
703
  });
706
- await this.transport.dispatch({
704
+ await this.pool.dispatch({
707
705
  runId,
708
706
  seq,
709
707
  name: step.name,
710
708
  stepId: id,
711
709
  group: step.group,
712
710
  input: validInput,
713
- attempt: 1,
714
- });
711
+ traceparent: this.traceparent?.(),
712
+ attempt,
713
+ }, transport);
715
714
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
716
715
  throw new errors_1.WorkflowSuspended();
717
716
  }
@@ -724,6 +723,9 @@ class WorkflowEngine {
724
723
  const cp = await this.store.getCheckpoint(result.runId, result.seq);
725
724
  if (!cp || cp.status !== 'pending')
726
725
  return;
726
+ // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Done
727
+ // before the cancelled-run early-return below, so a cancellation can't leak the slot.
728
+ this.releaseQueueSlot(cp.stepId);
727
729
  // Drop a late result for a run that was cancelled/finished meanwhile — don't complete the step
728
730
  // or resume (the run is already terminal). This is the engine side of cooperative cancellation.
729
731
  const run = await this.store.getRun(result.runId);
@@ -753,9 +755,17 @@ class WorkflowEngine {
753
755
  });
754
756
  await this.resume(result.runId);
755
757
  }
758
+ /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
759
+ releaseQueueSlot(id) {
760
+ const queue = this.stepQueue.get(id);
761
+ if (queue === undefined)
762
+ return;
763
+ this.stepQueue.delete(id);
764
+ this.queues.get(queue)?.release();
765
+ }
756
766
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
757
- async callRemoteInMemory(runId, seq, step, input) {
758
- if (!this.transport)
767
+ async callRemoteInMemory(runId, seq, step, input, transport) {
768
+ if (this.pool.size === 0)
759
769
  throw new Error('remote steps require a Transport');
760
770
  const validInput = step.input.parse(input);
761
771
  const id = (0, protocol_1.stepId)(runId, seq);
@@ -771,15 +781,16 @@ class WorkflowEngine {
771
781
  const resultPromise = new Promise((resolve, reject) => {
772
782
  this.pending.set(id, { resolve, reject });
773
783
  });
774
- await this.transport.dispatch({
784
+ await this.pool.dispatch({
775
785
  runId,
776
786
  seq,
777
787
  name: step.name,
778
788
  stepId: id,
779
789
  group: step.group,
780
790
  input: validInput,
791
+ traceparent: this.traceparent?.(),
781
792
  attempt,
782
- });
793
+ }, transport);
783
794
  try {
784
795
  const resolution = step.timeoutMs
785
796
  ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs)