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