@rivetkit/workflow-engine 2.0.4-rc.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.
package/src/context.ts ADDED
@@ -0,0 +1,1892 @@
1
+ import type { Logger } from "pino";
2
+ import type { EngineDriver } from "./driver.js";
3
+ import {
4
+ CancelledError,
5
+ CriticalError,
6
+ EntryInProgressError,
7
+ EvictedError,
8
+ HistoryDivergedError,
9
+ JoinError,
10
+ MessageWaitError,
11
+ RaceError,
12
+ RollbackCheckpointError,
13
+ RollbackError,
14
+ RollbackStopError,
15
+ SleepError,
16
+ StepExhaustedError,
17
+ StepFailedError,
18
+ } from "./errors.js";
19
+ import {
20
+ appendLoopIteration,
21
+ appendName,
22
+ emptyLocation,
23
+ locationToKey,
24
+ registerName,
25
+ } from "./location.js";
26
+ import {
27
+ createEntry,
28
+ deleteEntriesWithPrefix,
29
+ flush,
30
+ getOrCreateMetadata,
31
+ loadMetadata,
32
+ setEntry,
33
+ } from "./storage.js";
34
+ import type {
35
+ BranchConfig,
36
+ BranchOutput,
37
+ BranchStatus,
38
+ Entry,
39
+ EntryKindType,
40
+ EntryMetadata,
41
+ Location,
42
+ LoopConfig,
43
+ LoopIterationResult,
44
+ LoopResult,
45
+ Message,
46
+ RollbackContextInterface,
47
+ StepConfig,
48
+ Storage,
49
+ WorkflowContextInterface,
50
+ WorkflowQueue,
51
+ WorkflowQueueMessage,
52
+ WorkflowQueueNextBatchOptions,
53
+ WorkflowQueueNextOptions,
54
+ WorkflowMessageDriver,
55
+ } from "./types.js";
56
+ import { sleep } from "./utils.js";
57
+
58
+ /**
59
+ * Default values for step configuration.
60
+ * These are exported so users can reference them when overriding.
61
+ */
62
+ export const DEFAULT_MAX_RETRIES = 3;
63
+ export const DEFAULT_RETRY_BACKOFF_BASE = 100;
64
+ export const DEFAULT_RETRY_BACKOFF_MAX = 30000;
65
+ export const DEFAULT_LOOP_COMMIT_INTERVAL = 20;
66
+ export const DEFAULT_LOOP_HISTORY_EVERY = 20;
67
+ export const DEFAULT_LOOP_HISTORY_KEEP = 20;
68
+ export const DEFAULT_STEP_TIMEOUT = 30000; // 30 seconds
69
+
70
+ const QUEUE_HISTORY_MESSAGE_MARKER = "__rivetWorkflowQueueMessage";
71
+
72
+ /**
73
+ * Calculate backoff delay with exponential backoff.
74
+ * Uses deterministic calculation (no jitter) for replay consistency.
75
+ */
76
+ function calculateBackoff(attempts: number, base: number, max: number): number {
77
+ // Exponential backoff without jitter for determinism
78
+ return Math.min(max, base * 2 ** attempts);
79
+ }
80
+
81
+ /**
82
+ * Error thrown when a step times out.
83
+ */
84
+ export class StepTimeoutError extends Error {
85
+ constructor(
86
+ public readonly stepName: string,
87
+ public readonly timeoutMs: number,
88
+ ) {
89
+ super(`Step "${stepName}" timed out after ${timeoutMs}ms`);
90
+ this.name = "StepTimeoutError";
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Internal representation of a rollback handler.
96
+ */
97
+ export interface RollbackAction<T = unknown> {
98
+ entryId: string;
99
+ name: string;
100
+ output: T;
101
+ rollback: (ctx: RollbackContextInterface, output: T) => Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * Internal implementation of WorkflowContext.
106
+ */
107
+ export class WorkflowContextImpl implements WorkflowContextInterface {
108
+ private entryInProgress = false;
109
+ private abortController: AbortController;
110
+ private currentLocation: Location;
111
+ private visitedKeys = new Set<string>();
112
+ private mode: "forward" | "rollback";
113
+ private rollbackActions?: RollbackAction[];
114
+ private rollbackCheckpointSet: boolean;
115
+ /** Track names used in current execution to detect duplicates */
116
+ private usedNamesInExecution = new Set<string>();
117
+ private pendingCompletableMessageIds = new Set<string>();
118
+ private historyNotifier?: () => void;
119
+ private logger?: Logger;
120
+
121
+ constructor(
122
+ public readonly workflowId: string,
123
+ private storage: Storage,
124
+ private driver: EngineDriver,
125
+ private messageDriver: WorkflowMessageDriver,
126
+ location: Location = emptyLocation(),
127
+ abortController?: AbortController,
128
+ mode: "forward" | "rollback" = "forward",
129
+ rollbackActions?: RollbackAction[],
130
+ rollbackCheckpointSet = false,
131
+ historyNotifier?: () => void,
132
+ logger?: Logger,
133
+ ) {
134
+ this.currentLocation = location;
135
+ this.abortController = abortController ?? new AbortController();
136
+ this.mode = mode;
137
+ this.rollbackActions = rollbackActions;
138
+ this.rollbackCheckpointSet = rollbackCheckpointSet;
139
+ this.historyNotifier = historyNotifier;
140
+ this.logger = logger;
141
+ }
142
+
143
+ get abortSignal(): AbortSignal {
144
+ return this.abortController.signal;
145
+ }
146
+
147
+ get queue(): WorkflowQueue {
148
+ return {
149
+ next: async (name, opts) => await this.queueNext(name, opts),
150
+ nextBatch: async (name, opts) => await this.queueNextBatch(name, opts),
151
+ send: async (name, body) => await this.queueSend(name, body),
152
+ };
153
+ }
154
+
155
+ isEvicted(): boolean {
156
+ return this.abortSignal.aborted;
157
+ }
158
+
159
+ private assertNotInProgress(): void {
160
+ if (this.entryInProgress) {
161
+ throw new EntryInProgressError();
162
+ }
163
+ }
164
+
165
+ private checkEvicted(): void {
166
+ if (this.abortSignal.aborted) {
167
+ throw new EvictedError();
168
+ }
169
+ }
170
+
171
+ private async flushStorage(): Promise<void> {
172
+ await flush(this.storage, this.driver, this.historyNotifier);
173
+ }
174
+
175
+ /**
176
+ * Create a new branch context for parallel/nested execution.
177
+ */
178
+ createBranch(
179
+ location: Location,
180
+ abortController?: AbortController,
181
+ ): WorkflowContextImpl {
182
+ return new WorkflowContextImpl(
183
+ this.workflowId,
184
+ this.storage,
185
+ this.driver,
186
+ this.messageDriver,
187
+ location,
188
+ abortController ?? this.abortController,
189
+ this.mode,
190
+ this.rollbackActions,
191
+ this.rollbackCheckpointSet,
192
+ this.historyNotifier,
193
+ this.logger,
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Log a debug message using the configured logger.
199
+ */
200
+ private log(level: "debug" | "info" | "warn" | "error", data: Record<string, unknown>): void {
201
+ if (!this.logger) return;
202
+ this.logger[level](data);
203
+ }
204
+
205
+ /**
206
+ * Mark a key as visited.
207
+ */
208
+ private markVisited(key: string): void {
209
+ this.visitedKeys.add(key);
210
+ }
211
+
212
+ /**
213
+ * Check if a name has already been used at the current location in this execution.
214
+ * Throws HistoryDivergedError if duplicate detected.
215
+ */
216
+ private checkDuplicateName(name: string): void {
217
+ const fullKey =
218
+ locationToKey(this.storage, this.currentLocation) + "/" + name;
219
+ if (this.usedNamesInExecution.has(fullKey)) {
220
+ throw new HistoryDivergedError(
221
+ `Duplicate entry name "${name}" at location "${locationToKey(this.storage, this.currentLocation)}". ` +
222
+ `Each step/loop/sleep/queue.next/join/race must have a unique name within its scope.`,
223
+ );
224
+ }
225
+ this.usedNamesInExecution.add(fullKey);
226
+ }
227
+
228
+ private stopRollback(): never {
229
+ throw new RollbackStopError();
230
+ }
231
+
232
+ private stopRollbackIfMissing(entry: Entry | undefined): void {
233
+ if (this.mode === "rollback" && !entry) {
234
+ this.stopRollback();
235
+ }
236
+ }
237
+
238
+ private stopRollbackIfIncomplete(condition: boolean): void {
239
+ if (this.mode === "rollback" && condition) {
240
+ this.stopRollback();
241
+ }
242
+ }
243
+
244
+ private registerRollbackAction<T>(
245
+ config: StepConfig<T>,
246
+ entryId: string,
247
+ output: T,
248
+ metadata: EntryMetadata,
249
+ ): void {
250
+ if (!config.rollback) {
251
+ return;
252
+ }
253
+ if (metadata.rollbackCompletedAt !== undefined) {
254
+ return;
255
+ }
256
+ this.rollbackActions?.push({
257
+ entryId,
258
+ name: config.name,
259
+ output: output as unknown,
260
+ rollback: config.rollback as (
261
+ ctx: RollbackContextInterface,
262
+ output: unknown,
263
+ ) => Promise<void>,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Ensure a rollback checkpoint exists before registering rollback handlers.
269
+ */
270
+ private ensureRollbackCheckpoint<T>(config: StepConfig<T>): void {
271
+ if (!config.rollback) {
272
+ return;
273
+ }
274
+ if (!this.rollbackCheckpointSet) {
275
+ throw new RollbackCheckpointError();
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Validate that all expected entries in the branch were visited.
281
+ * Throws HistoryDivergedError if there are unvisited entries.
282
+ */
283
+ validateComplete(): void {
284
+ const prefix = locationToKey(this.storage, this.currentLocation);
285
+
286
+ for (const key of this.storage.history.entries.keys()) {
287
+ // Check if this key is under our current location prefix
288
+ // Handle root prefix (empty string) specially - all keys are under root
289
+ const isUnderPrefix =
290
+ prefix === ""
291
+ ? true // Root: all keys are children
292
+ : key.startsWith(prefix + "/") || key === prefix;
293
+
294
+ if (isUnderPrefix) {
295
+ if (!this.visitedKeys.has(key)) {
296
+ // Entry exists in history but wasn't visited
297
+ // This means workflow code may have changed
298
+ throw new HistoryDivergedError(
299
+ `Entry "${key}" exists in history but was not visited. ` +
300
+ `Workflow code may have changed. Use ctx.removed() to handle migrations.`,
301
+ );
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Evict the workflow.
309
+ */
310
+ evict(): void {
311
+ this.abortController.abort(new EvictedError());
312
+ }
313
+
314
+ /**
315
+ * Wait for eviction message.
316
+ *
317
+ * The event listener uses { once: true } to auto-remove after firing,
318
+ * preventing memory leaks if this method is called multiple times.
319
+ */
320
+ waitForEviction(): Promise<never> {
321
+ return new Promise((_, reject) => {
322
+ if (this.abortSignal.aborted) {
323
+ reject(new EvictedError());
324
+ return;
325
+ }
326
+ this.abortSignal.addEventListener(
327
+ "abort",
328
+ () => {
329
+ reject(new EvictedError());
330
+ },
331
+ { once: true },
332
+ );
333
+ });
334
+ }
335
+
336
+ // === Step ===
337
+
338
+ async step<T>(
339
+ nameOrConfig: string | StepConfig<T>,
340
+ run?: () => Promise<T>,
341
+ ): Promise<T> {
342
+ this.assertNotInProgress();
343
+ this.checkEvicted();
344
+
345
+ const config: StepConfig<T> =
346
+ typeof nameOrConfig === "string"
347
+ ? { name: nameOrConfig, run: run! }
348
+ : nameOrConfig;
349
+
350
+ this.entryInProgress = true;
351
+ try {
352
+ return await this.executeStep(config);
353
+ } finally {
354
+ this.entryInProgress = false;
355
+ }
356
+ }
357
+
358
+ private async executeStep<T>(config: StepConfig<T>): Promise<T> {
359
+ this.ensureRollbackCheckpoint(config);
360
+ if (this.mode === "rollback") {
361
+ return await this.executeStepRollback(config);
362
+ }
363
+
364
+ // Check for duplicate name in current execution
365
+ this.checkDuplicateName(config.name);
366
+
367
+ const location = appendName(
368
+ this.storage,
369
+ this.currentLocation,
370
+ config.name,
371
+ );
372
+ const key = locationToKey(this.storage, location);
373
+ const existing = this.storage.history.entries.get(key);
374
+
375
+ // Mark this entry as visited for validateComplete
376
+ this.markVisited(key);
377
+
378
+ if (existing) {
379
+ if (existing.kind.type !== "step") {
380
+ throw new HistoryDivergedError(
381
+ `Expected step "${config.name}" at ${key}, found ${existing.kind.type}`,
382
+ );
383
+ }
384
+
385
+ const stepData = existing.kind.data;
386
+
387
+ const metadata = await loadMetadata(
388
+ this.storage,
389
+ this.driver,
390
+ existing.id,
391
+ );
392
+
393
+ // Replay successful result (including void steps).
394
+ if (metadata.status === "completed" || stepData.output !== undefined) {
395
+ return stepData.output as T;
396
+ }
397
+
398
+ // Check if we should retry
399
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
400
+
401
+ if (metadata.attempts >= maxRetries) {
402
+ // Prefer step history error, but fall back to metadata since
403
+ // driver implementations may persist metadata without the history
404
+ // entry error (e.g. partial writes/crashes between attempts).
405
+ const lastError = stepData.error ?? metadata.error;
406
+ throw new StepExhaustedError(config.name, lastError);
407
+ }
408
+
409
+ // Calculate backoff and yield to scheduler
410
+ // This allows the workflow to be evicted during backoff
411
+ const backoffDelay = calculateBackoff(
412
+ metadata.attempts,
413
+ config.retryBackoffBase ?? DEFAULT_RETRY_BACKOFF_BASE,
414
+ config.retryBackoffMax ?? DEFAULT_RETRY_BACKOFF_MAX,
415
+ );
416
+ const retryAt = metadata.lastAttemptAt + backoffDelay;
417
+ const now = Date.now();
418
+
419
+ if (now < retryAt) {
420
+ // Yield to scheduler - will be woken up at retryAt
421
+ throw new SleepError(retryAt);
422
+ }
423
+ }
424
+
425
+ // Execute the step
426
+ const entry =
427
+ existing ?? createEntry(location, { type: "step", data: {} });
428
+ if (!existing) {
429
+ // New entry - register name
430
+ this.log("debug", { msg: "executing new step", step: config.name, key });
431
+ const nameIndex = registerName(this.storage, config.name);
432
+ entry.location = [...location];
433
+ entry.location[entry.location.length - 1] = nameIndex;
434
+ setEntry(this.storage, location, entry);
435
+ } else {
436
+ this.log("debug", { msg: "retrying step", step: config.name, key });
437
+ }
438
+
439
+ const metadata = getOrCreateMetadata(this.storage, entry.id);
440
+ metadata.status = "running";
441
+ metadata.attempts++;
442
+ metadata.lastAttemptAt = Date.now();
443
+ metadata.dirty = true;
444
+
445
+ // Get timeout configuration
446
+ const timeout = config.timeout ?? DEFAULT_STEP_TIMEOUT;
447
+
448
+ try {
449
+ // Execute with timeout
450
+ const output = await this.executeWithTimeout(
451
+ config.run(),
452
+ timeout,
453
+ config.name,
454
+ );
455
+
456
+ if (entry.kind.type === "step") {
457
+ entry.kind.data.output = output;
458
+ }
459
+ entry.dirty = true;
460
+ metadata.status = "completed";
461
+ metadata.error = undefined;
462
+ metadata.completedAt = Date.now();
463
+
464
+ // Ephemeral steps don't trigger an immediate flush. This avoids the
465
+ // synchronous write overhead for transient operations. Note that the
466
+ // step's entry is still marked dirty and WILL be persisted on the
467
+ // next flush from a non-ephemeral operation. The purpose of ephemeral
468
+ // is to batch writes, not to avoid persistence entirely.
469
+ if (!config.ephemeral) {
470
+ this.log("debug", { msg: "flushing step", step: config.name, key });
471
+ await this.flushStorage();
472
+ }
473
+
474
+ this.log("debug", { msg: "step completed", step: config.name, key });
475
+ return output;
476
+ } catch (error) {
477
+ // Timeout errors are treated as critical (no retry)
478
+ if (error instanceof StepTimeoutError) {
479
+ if (entry.kind.type === "step") {
480
+ entry.kind.data.error = String(error);
481
+ }
482
+ entry.dirty = true;
483
+ metadata.status = "exhausted";
484
+ metadata.error = String(error);
485
+ await this.flushStorage();
486
+ throw new CriticalError(error.message);
487
+ }
488
+
489
+ if (
490
+ error instanceof CriticalError ||
491
+ error instanceof RollbackError
492
+ ) {
493
+ if (entry.kind.type === "step") {
494
+ entry.kind.data.error = String(error);
495
+ }
496
+ entry.dirty = true;
497
+ metadata.status = "exhausted";
498
+ metadata.error = String(error);
499
+ await this.flushStorage();
500
+ throw error;
501
+ }
502
+
503
+ if (entry.kind.type === "step") {
504
+ entry.kind.data.error = String(error);
505
+ }
506
+ entry.dirty = true;
507
+ metadata.status = "failed";
508
+ metadata.error = String(error);
509
+
510
+ await this.flushStorage();
511
+
512
+ throw new StepFailedError(config.name, error, metadata.attempts);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Execute a promise with timeout.
518
+ *
519
+ * Note: This does NOT cancel the underlying operation. JavaScript Promises
520
+ * cannot be cancelled once started. When a timeout occurs:
521
+ * - The step is marked as failed with StepTimeoutError
522
+ * - The underlying async operation continues running in the background
523
+ * - Any side effects from the operation may still occur
524
+ *
525
+ * For cancellable operations, pass ctx.abortSignal to APIs that support AbortSignal:
526
+ *
527
+ * return fetch(url, { signal: ctx.abortSignal });
528
+
529
+ * });
530
+ *
531
+ * Or check ctx.isEvicted() periodically in long-running loops.
532
+ */
533
+ private async executeStepRollback<T>(config: StepConfig<T>): Promise<T> {
534
+ this.checkDuplicateName(config.name);
535
+ this.ensureRollbackCheckpoint(config);
536
+
537
+ const location = appendName(
538
+ this.storage,
539
+ this.currentLocation,
540
+ config.name,
541
+ );
542
+ const key = locationToKey(this.storage, location);
543
+ const existing = this.storage.history.entries.get(key);
544
+
545
+ this.markVisited(key);
546
+
547
+ if (!existing || existing.kind.type !== "step") {
548
+ this.stopRollback();
549
+ }
550
+
551
+ const metadata = await loadMetadata(
552
+ this.storage,
553
+ this.driver,
554
+ existing.id,
555
+ );
556
+ if (metadata.status !== "completed") {
557
+ this.stopRollback();
558
+ }
559
+
560
+ const output = existing.kind.data.output as T;
561
+ this.registerRollbackAction(config, existing.id, output, metadata);
562
+
563
+ return output;
564
+ }
565
+
566
+ private async executeWithTimeout<T>(
567
+ promise: Promise<T>,
568
+ timeoutMs: number,
569
+ stepName: string,
570
+ ): Promise<T> {
571
+ if (timeoutMs <= 0) {
572
+ return promise;
573
+ }
574
+
575
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
576
+ const timeoutPromise = new Promise<never>((_, reject) => {
577
+ timeoutId = setTimeout(() => {
578
+ reject(new StepTimeoutError(stepName, timeoutMs));
579
+ }, timeoutMs);
580
+ });
581
+
582
+ try {
583
+ return await Promise.race([promise, timeoutPromise]);
584
+ } finally {
585
+ if (timeoutId !== undefined) {
586
+ clearTimeout(timeoutId);
587
+ }
588
+ }
589
+ }
590
+
591
+ // === Loop ===
592
+
593
+ async loop<S, T>(
594
+ nameOrConfig: string | LoopConfig<S, T>,
595
+ run?: (
596
+ ctx: WorkflowContextInterface,
597
+ ) => LoopIterationResult<undefined, T>,
598
+ ): Promise<T> {
599
+ this.assertNotInProgress();
600
+ this.checkEvicted();
601
+
602
+ const config: LoopConfig<S, T> =
603
+ typeof nameOrConfig === "string"
604
+ ? { name: nameOrConfig, run: run as LoopConfig<S, T>["run"] }
605
+ : nameOrConfig;
606
+
607
+ this.entryInProgress = true;
608
+ try {
609
+ return await this.executeLoop(config);
610
+ } finally {
611
+ this.entryInProgress = false;
612
+ }
613
+ }
614
+
615
+ private async executeLoop<S, T>(config: LoopConfig<S, T>): Promise<T> {
616
+ // Check for duplicate name in current execution
617
+ this.checkDuplicateName(config.name);
618
+
619
+ const location = appendName(
620
+ this.storage,
621
+ this.currentLocation,
622
+ config.name,
623
+ );
624
+ const key = locationToKey(this.storage, location);
625
+ const existing = this.storage.history.entries.get(key);
626
+
627
+ // Mark this entry as visited for validateComplete
628
+ this.markVisited(key);
629
+
630
+ let entry: Entry;
631
+ let metadata: EntryMetadata | undefined;
632
+ let state: S;
633
+ let iteration: number;
634
+ let rollbackSingleIteration = false;
635
+ let rollbackIterationRan = false;
636
+ let rollbackOutput: T | undefined;
637
+ const rollbackMode = this.mode === "rollback";
638
+
639
+ if (existing) {
640
+ if (existing.kind.type !== "loop") {
641
+ throw new HistoryDivergedError(
642
+ `Expected loop "${config.name}" at ${key}, found ${existing.kind.type}`,
643
+ );
644
+ }
645
+
646
+ const loopData = existing.kind.data;
647
+ metadata = await loadMetadata(this.storage, this.driver, existing.id);
648
+
649
+ if (rollbackMode) {
650
+ if (loopData.output !== undefined) {
651
+ return loopData.output as T;
652
+ }
653
+ rollbackSingleIteration = true;
654
+ rollbackIterationRan = false;
655
+ rollbackOutput = undefined;
656
+ }
657
+
658
+ if (metadata.status === "completed") {
659
+ return loopData.output as T;
660
+ }
661
+
662
+ // Loop already completed
663
+ if (loopData.output !== undefined) {
664
+ return loopData.output as T;
665
+ }
666
+
667
+ // Resume from saved state
668
+ entry = existing;
669
+ state = loopData.state as S;
670
+ iteration = loopData.iteration;
671
+ if (rollbackMode) {
672
+ rollbackOutput = loopData.output as T | undefined;
673
+ rollbackIterationRan = rollbackOutput !== undefined;
674
+ }
675
+ } else {
676
+ this.stopRollbackIfIncomplete(true);
677
+
678
+ // New loop
679
+ state = config.state as S;
680
+ iteration = 0;
681
+ entry = createEntry(location, {
682
+ type: "loop",
683
+ data: { state, iteration },
684
+ });
685
+ setEntry(this.storage, location, entry);
686
+ metadata = getOrCreateMetadata(this.storage, entry.id);
687
+ }
688
+
689
+ if (metadata) {
690
+ metadata.status = "running";
691
+ metadata.error = undefined;
692
+ metadata.dirty = true;
693
+ }
694
+
695
+ // TODO: Add validation for commitInterval (must be > 0)
696
+ const commitInterval =
697
+ config.commitInterval ?? DEFAULT_LOOP_COMMIT_INTERVAL;
698
+ const historyEvery =
699
+ config.historyEvery ??
700
+ config.commitInterval ??
701
+ DEFAULT_LOOP_HISTORY_EVERY;
702
+ const historyKeep =
703
+ config.historyKeep ??
704
+ config.commitInterval ??
705
+ DEFAULT_LOOP_HISTORY_KEEP;
706
+
707
+ // Execute loop iterations
708
+ while (true) {
709
+ if (rollbackMode && rollbackSingleIteration) {
710
+ if (rollbackIterationRan) {
711
+ return rollbackOutput as T;
712
+ }
713
+ this.stopRollbackIfIncomplete(true);
714
+ }
715
+ this.checkEvicted();
716
+
717
+ // Create branch for this iteration
718
+ const iterationLocation = appendLoopIteration(
719
+ this.storage,
720
+ location,
721
+ config.name,
722
+ iteration,
723
+ );
724
+ const branchCtx = this.createBranch(iterationLocation);
725
+
726
+ // Execute iteration
727
+ const iterationResult = await config.run(branchCtx, state);
728
+ if (iterationResult === undefined && state !== undefined) {
729
+ throw new Error(
730
+ `Loop "${config.name}" returned undefined for a stateful iteration. Return Loop.continue(state) or Loop.break(value).`,
731
+ );
732
+ }
733
+ const result =
734
+ iterationResult === undefined
735
+ ? ({ continue: true, state } as LoopResult<S, T>)
736
+ : iterationResult;
737
+
738
+ // Validate branch completed cleanly
739
+ branchCtx.validateComplete();
740
+
741
+ if ("break" in result && result.break) {
742
+ // Loop complete
743
+ if (entry.kind.type === "loop") {
744
+ entry.kind.data.output = result.value;
745
+ entry.kind.data.state = state;
746
+ entry.kind.data.iteration = iteration;
747
+ }
748
+ entry.dirty = true;
749
+ if (metadata) {
750
+ metadata.status = "completed";
751
+ metadata.completedAt = Date.now();
752
+ metadata.dirty = true;
753
+ }
754
+
755
+ await this.flushStorage();
756
+ await this.forgetOldIterations(
757
+ location,
758
+ iteration + 1,
759
+ historyEvery,
760
+ historyKeep,
761
+ );
762
+
763
+ if (rollbackMode && rollbackSingleIteration) {
764
+ rollbackOutput = result.value;
765
+ rollbackIterationRan = true;
766
+ continue;
767
+ }
768
+
769
+ return result.value;
770
+ }
771
+
772
+ // Continue with new state
773
+ if ("continue" in result && result.continue) {
774
+ state = result.state;
775
+ }
776
+ iteration++;
777
+
778
+ // Periodic commit
779
+ if (iteration % commitInterval === 0) {
780
+ if (entry.kind.type === "loop") {
781
+ entry.kind.data.state = state;
782
+ entry.kind.data.iteration = iteration;
783
+ }
784
+ entry.dirty = true;
785
+
786
+ await this.flushStorage();
787
+ await this.forgetOldIterations(
788
+ location,
789
+ iteration,
790
+ historyEvery,
791
+ historyKeep,
792
+ );
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Delete old loop iteration entries to save storage space.
799
+ *
800
+ * Loop locations always end with a NameIndex (number) because loops are
801
+ * created via appendName(). Even for nested loops, the innermost loop's
802
+ * location ends with its name index:
803
+ *
804
+ * ctx.loop("outer") → location: [outerIndex]
805
+ * iteration 0 → location: [{ loop: outerIndex, iteration: 0 }]
806
+ * ctx.loop("inner") → location: [{ loop: outerIndex, iteration: 0 }, innerIndex]
807
+ *
808
+ * This function removes iterations older than (currentIteration - historyKeep)
809
+ * every historyEvery iterations.
810
+ */
811
+ private async forgetOldIterations(
812
+ loopLocation: Location,
813
+ currentIteration: number,
814
+ historyEvery: number,
815
+ historyKeep: number,
816
+ ): Promise<void> {
817
+ if (historyEvery <= 0 || historyKeep <= 0) {
818
+ return;
819
+ }
820
+ if (currentIteration === 0 || currentIteration % historyEvery !== 0) {
821
+ return;
822
+ }
823
+ const keepFrom = Math.max(0, currentIteration - historyKeep);
824
+ // Get the loop name index from the last segment of loopLocation.
825
+ // This is always a NameIndex (number) because loop entries are created
826
+ // via appendName(), not appendLoopIteration().
827
+ const loopSegment = loopLocation[loopLocation.length - 1];
828
+ if (typeof loopSegment !== "number") {
829
+ throw new Error("Expected loop location to end with a name index");
830
+ }
831
+
832
+ for (let i = 0; i < keepFrom; i++) {
833
+ const iterationLocation: Location = [
834
+ ...loopLocation,
835
+ { loop: loopSegment, iteration: i },
836
+ ];
837
+ await deleteEntriesWithPrefix(
838
+ this.storage,
839
+ this.driver,
840
+ iterationLocation,
841
+ this.historyNotifier,
842
+ );
843
+ }
844
+ }
845
+
846
+ // === Sleep ===
847
+
848
+ async sleep(name: string, durationMs: number): Promise<void> {
849
+ const deadline = Date.now() + durationMs;
850
+ return this.sleepUntil(name, deadline);
851
+ }
852
+
853
+ async sleepUntil(name: string, timestampMs: number): Promise<void> {
854
+ this.assertNotInProgress();
855
+ this.checkEvicted();
856
+
857
+ this.entryInProgress = true;
858
+ try {
859
+ await this.executeSleep(name, timestampMs);
860
+ } finally {
861
+ this.entryInProgress = false;
862
+ }
863
+ }
864
+
865
+ private async executeSleep(name: string, deadline: number): Promise<void> {
866
+ // Check for duplicate name in current execution
867
+ this.checkDuplicateName(name);
868
+
869
+ const location = appendName(this.storage, this.currentLocation, name);
870
+ const key = locationToKey(this.storage, location);
871
+ const existing = this.storage.history.entries.get(key);
872
+
873
+ // Mark this entry as visited for validateComplete
874
+ this.markVisited(key);
875
+
876
+ let entry: Entry;
877
+
878
+ if (existing) {
879
+ if (existing.kind.type !== "sleep") {
880
+ throw new HistoryDivergedError(
881
+ `Expected sleep "${name}" at ${key}, found ${existing.kind.type}`,
882
+ );
883
+ }
884
+
885
+ const sleepData = existing.kind.data;
886
+
887
+ if (this.mode === "rollback") {
888
+ this.stopRollbackIfIncomplete(sleepData.state === "pending");
889
+ return;
890
+ }
891
+
892
+ // Already completed or interrupted
893
+ if (sleepData.state !== "pending") {
894
+ return;
895
+ }
896
+
897
+ // Use stored deadline
898
+ deadline = sleepData.deadline;
899
+ entry = existing;
900
+ } else {
901
+ this.stopRollbackIfIncomplete(true);
902
+
903
+ entry = createEntry(location, {
904
+ type: "sleep",
905
+ data: { deadline, state: "pending" },
906
+ });
907
+ setEntry(this.storage, location, entry);
908
+ entry.dirty = true;
909
+ await this.flushStorage();
910
+ }
911
+
912
+ const now = Date.now();
913
+ const remaining = deadline - now;
914
+
915
+ if (remaining <= 0) {
916
+ // Deadline passed
917
+ if (entry.kind.type === "sleep") {
918
+ entry.kind.data.state = "completed";
919
+ }
920
+ entry.dirty = true;
921
+ await this.flushStorage();
922
+ return;
923
+ }
924
+
925
+ // Short sleep: wait in memory
926
+ if (remaining < this.driver.workerPollInterval) {
927
+ await Promise.race([sleep(remaining), this.waitForEviction()]);
928
+
929
+ this.checkEvicted();
930
+
931
+ if (entry.kind.type === "sleep") {
932
+ entry.kind.data.state = "completed";
933
+ }
934
+ entry.dirty = true;
935
+ await this.flushStorage();
936
+ return;
937
+ }
938
+
939
+ // Long sleep: yield to scheduler
940
+ throw new SleepError(deadline);
941
+ }
942
+
943
+ // === Rollback Checkpoint ===
944
+
945
+ async rollbackCheckpoint(name: string): Promise<void> {
946
+ this.assertNotInProgress();
947
+ this.checkEvicted();
948
+
949
+ this.entryInProgress = true;
950
+ try {
951
+ await this.executeRollbackCheckpoint(name);
952
+ } finally {
953
+ this.entryInProgress = false;
954
+ }
955
+ }
956
+
957
+ private async executeRollbackCheckpoint(name: string): Promise<void> {
958
+ this.checkDuplicateName(name);
959
+
960
+ const location = appendName(this.storage, this.currentLocation, name);
961
+ const key = locationToKey(this.storage, location);
962
+ const existing = this.storage.history.entries.get(key);
963
+
964
+ this.markVisited(key);
965
+
966
+ if (existing) {
967
+ if (existing.kind.type !== "rollback_checkpoint") {
968
+ throw new HistoryDivergedError(
969
+ `Expected rollback checkpoint "${name}" at ${key}, found ${existing.kind.type}`,
970
+ );
971
+ }
972
+ this.rollbackCheckpointSet = true;
973
+ return;
974
+ }
975
+
976
+ if (this.mode === "rollback") {
977
+ throw new HistoryDivergedError(
978
+ `Missing rollback checkpoint "${name}" at ${key}`,
979
+ );
980
+ }
981
+
982
+ const entry = createEntry(location, {
983
+ type: "rollback_checkpoint",
984
+ data: { name },
985
+ });
986
+ setEntry(this.storage, location, entry);
987
+ entry.dirty = true;
988
+ await this.flushStorage();
989
+
990
+ this.rollbackCheckpointSet = true;
991
+ }
992
+
993
+ // === Queue ===
994
+
995
+ private async queueSend(name: string, body: unknown): Promise<void> {
996
+ const message: Message = {
997
+ id: crypto.randomUUID(),
998
+ name,
999
+ data: body,
1000
+ sentAt: Date.now(),
1001
+ };
1002
+ await this.messageDriver.addMessage(message);
1003
+ }
1004
+
1005
+ private async queueNext<T>(
1006
+ name: string,
1007
+ opts?: WorkflowQueueNextOptions,
1008
+ ): Promise<WorkflowQueueMessage<T>> {
1009
+ const messages = await this.queueNextBatch<T>(name, {
1010
+ ...(opts ?? {}),
1011
+ count: 1,
1012
+ });
1013
+ const message = messages[0];
1014
+ if (!message) {
1015
+ throw new Error(
1016
+ `queue.next("${name}") timed out before receiving a message. Use queue.nextBatch(...) for optional/time-limited reads.`,
1017
+ );
1018
+ }
1019
+ return message;
1020
+ }
1021
+
1022
+ private async queueNextBatch<T>(
1023
+ name: string,
1024
+ opts?: WorkflowQueueNextBatchOptions,
1025
+ ): Promise<Array<WorkflowQueueMessage<T>>> {
1026
+ this.assertNotInProgress();
1027
+ this.checkEvicted();
1028
+
1029
+ this.entryInProgress = true;
1030
+ try {
1031
+ return await this.executeQueueNextBatch<T>(name, opts);
1032
+ } finally {
1033
+ this.entryInProgress = false;
1034
+ }
1035
+ }
1036
+
1037
+ private async executeQueueNextBatch<T>(
1038
+ name: string,
1039
+ opts?: WorkflowQueueNextBatchOptions,
1040
+ ): Promise<Array<WorkflowQueueMessage<T>>> {
1041
+ if (this.pendingCompletableMessageIds.size > 0) {
1042
+ throw new Error(
1043
+ "Previous completable queue message is not completed. Call `message.complete(...)` before receiving the next message.",
1044
+ );
1045
+ }
1046
+
1047
+ const resolvedOpts = opts ?? {};
1048
+ const messageNames = this.normalizeQueueNames(resolvedOpts.names);
1049
+ const messageNameLabel = this.messageNamesLabel(messageNames);
1050
+ const count = Math.max(1, resolvedOpts.count ?? 1);
1051
+ const completable = resolvedOpts.completable === true;
1052
+
1053
+ this.checkDuplicateName(name);
1054
+
1055
+ const countLocation = appendName(
1056
+ this.storage,
1057
+ this.currentLocation,
1058
+ `${name}:count`,
1059
+ );
1060
+ const countKey = locationToKey(this.storage, countLocation);
1061
+ const existingCount = this.storage.history.entries.get(countKey);
1062
+ this.markVisited(countKey);
1063
+ this.stopRollbackIfMissing(existingCount);
1064
+
1065
+ let deadline: number | undefined;
1066
+ let deadlineEntry: Entry | undefined;
1067
+ if (resolvedOpts.timeout !== undefined) {
1068
+ const deadlineLocation = appendName(
1069
+ this.storage,
1070
+ this.currentLocation,
1071
+ `${name}:deadline`,
1072
+ );
1073
+ const deadlineKey = locationToKey(this.storage, deadlineLocation);
1074
+ deadlineEntry = this.storage.history.entries.get(deadlineKey);
1075
+ this.markVisited(deadlineKey);
1076
+ this.stopRollbackIfMissing(deadlineEntry);
1077
+
1078
+ if (deadlineEntry && deadlineEntry.kind.type === "sleep") {
1079
+ deadline = deadlineEntry.kind.data.deadline;
1080
+ } else {
1081
+ deadline = Date.now() + resolvedOpts.timeout;
1082
+ const created = createEntry(deadlineLocation, {
1083
+ type: "sleep",
1084
+ data: { deadline, state: "pending" },
1085
+ });
1086
+ setEntry(this.storage, deadlineLocation, created);
1087
+ created.dirty = true;
1088
+ await this.flushStorage();
1089
+ deadlineEntry = created;
1090
+ }
1091
+ }
1092
+
1093
+ if (existingCount && existingCount.kind.type === "message") {
1094
+ const replayCount = existingCount.kind.data.data as number;
1095
+ return await this.readReplayQueueMessages<T>(
1096
+ name,
1097
+ replayCount,
1098
+ completable,
1099
+ );
1100
+ }
1101
+
1102
+ const now = Date.now();
1103
+ if (deadline !== undefined && now >= deadline) {
1104
+ if (deadlineEntry && deadlineEntry.kind.type === "sleep") {
1105
+ deadlineEntry.kind.data.state = "completed";
1106
+ deadlineEntry.dirty = true;
1107
+ }
1108
+ await this.recordQueueCountEntry(
1109
+ countLocation,
1110
+ `${messageNameLabel}:count`,
1111
+ 0,
1112
+ );
1113
+ return [];
1114
+ }
1115
+
1116
+ const received = await this.receiveMessagesNow(
1117
+ messageNames,
1118
+ count,
1119
+ completable,
1120
+ );
1121
+ if (received.length > 0) {
1122
+ const historyMessages = received.map((message) =>
1123
+ this.toWorkflowQueueMessage<T>(message),
1124
+ );
1125
+ if (deadlineEntry && deadlineEntry.kind.type === "sleep") {
1126
+ deadlineEntry.kind.data.state = "interrupted";
1127
+ deadlineEntry.dirty = true;
1128
+ }
1129
+ await this.recordQueueMessages(
1130
+ name,
1131
+ countLocation,
1132
+ messageNames,
1133
+ historyMessages,
1134
+ );
1135
+ const queueMessages = received.map((message, index) =>
1136
+ this.createQueueMessage<T>(message, completable, {
1137
+ historyLocation: appendName(
1138
+ this.storage,
1139
+ this.currentLocation,
1140
+ `${name}:${index}`,
1141
+ ),
1142
+ }),
1143
+ );
1144
+ return queueMessages;
1145
+ }
1146
+
1147
+ if (deadline === undefined) {
1148
+ throw new MessageWaitError(messageNames);
1149
+ }
1150
+ throw new SleepError(deadline, messageNames);
1151
+ }
1152
+
1153
+ private normalizeQueueNames(names?: readonly string[]): string[] {
1154
+ if (!names || names.length === 0) {
1155
+ return [];
1156
+ }
1157
+ const deduped: string[] = [];
1158
+ const seen = new Set<string>();
1159
+ for (const name of names) {
1160
+ if (seen.has(name)) {
1161
+ continue;
1162
+ }
1163
+ seen.add(name);
1164
+ deduped.push(name);
1165
+ }
1166
+ return deduped;
1167
+ }
1168
+
1169
+ private messageNamesLabel(messageNames: string[]): string {
1170
+ if (messageNames.length === 0) {
1171
+ return "*";
1172
+ }
1173
+ return messageNames.length === 1
1174
+ ? messageNames[0]
1175
+ : messageNames.join("|");
1176
+ }
1177
+
1178
+ private async receiveMessagesNow(
1179
+ messageNames: string[],
1180
+ count: number,
1181
+ completable: boolean,
1182
+ ): Promise<Message[]> {
1183
+ return await this.messageDriver.receiveMessages({
1184
+ names: messageNames.length > 0 ? messageNames : undefined,
1185
+ count,
1186
+ completable,
1187
+ });
1188
+ }
1189
+
1190
+ private async recordQueueMessages<T>(
1191
+ name: string,
1192
+ countLocation: Location,
1193
+ messageNames: string[],
1194
+ messages: Array<WorkflowQueueMessage<T>>,
1195
+ ): Promise<void> {
1196
+ for (let i = 0; i < messages.length; i++) {
1197
+ const messageLocation = appendName(
1198
+ this.storage,
1199
+ this.currentLocation,
1200
+ `${name}:${i}`,
1201
+ );
1202
+ const messageEntry = createEntry(messageLocation, {
1203
+ type: "message",
1204
+ data: {
1205
+ name: messages[i].name,
1206
+ data: this.toHistoryQueueMessage(messages[i]),
1207
+ },
1208
+ });
1209
+ setEntry(this.storage, messageLocation, messageEntry);
1210
+ this.markVisited(locationToKey(this.storage, messageLocation));
1211
+ }
1212
+ await this.recordQueueCountEntry(
1213
+ countLocation,
1214
+ `${this.messageNamesLabel(messageNames)}:count`,
1215
+ messages.length,
1216
+ );
1217
+ }
1218
+
1219
+ private async recordQueueCountEntry(
1220
+ countLocation: Location,
1221
+ countLabel: string,
1222
+ count: number,
1223
+ ): Promise<void> {
1224
+ const countEntry = createEntry(countLocation, {
1225
+ type: "message",
1226
+ data: {
1227
+ name: countLabel,
1228
+ data: count,
1229
+ },
1230
+ });
1231
+ setEntry(this.storage, countLocation, countEntry);
1232
+ await this.flushStorage();
1233
+ }
1234
+
1235
+ private async readReplayQueueMessages<T>(
1236
+ name: string,
1237
+ count: number,
1238
+ completable: boolean,
1239
+ ): Promise<Array<WorkflowQueueMessage<T>>> {
1240
+ const results: Array<WorkflowQueueMessage<T>> = [];
1241
+ for (let i = 0; i < count; i++) {
1242
+ const messageLocation = appendName(
1243
+ this.storage,
1244
+ this.currentLocation,
1245
+ `${name}:${i}`,
1246
+ );
1247
+ const messageKey = locationToKey(this.storage, messageLocation);
1248
+ this.markVisited(messageKey);
1249
+ const existingMessage = this.storage.history.entries.get(messageKey);
1250
+ if (!existingMessage || existingMessage.kind.type !== "message") {
1251
+ throw new HistoryDivergedError(
1252
+ `Expected queue message "${name}:${i}" in history`,
1253
+ );
1254
+ }
1255
+ const parsed = this.fromHistoryQueueMessage(
1256
+ existingMessage.kind.data.name,
1257
+ existingMessage.kind.data.data,
1258
+ );
1259
+ results.push(
1260
+ this.createQueueMessage<T>(parsed.message, completable, {
1261
+ historyLocation: messageLocation,
1262
+ completed: parsed.completed,
1263
+ replay: true,
1264
+ }),
1265
+ );
1266
+ }
1267
+ return results;
1268
+ }
1269
+
1270
+ private toWorkflowQueueMessage<T>(message: Message): WorkflowQueueMessage<T> {
1271
+ return {
1272
+ id: message.id,
1273
+ name: message.name,
1274
+ body: message.data as T,
1275
+ createdAt: message.sentAt,
1276
+ };
1277
+ }
1278
+
1279
+ private createQueueMessage<T>(
1280
+ message: Message,
1281
+ completable: boolean,
1282
+ opts?: {
1283
+ historyLocation?: Location;
1284
+ completed?: boolean;
1285
+ replay?: boolean;
1286
+ },
1287
+ ): WorkflowQueueMessage<T> {
1288
+ const queueMessage = this.toWorkflowQueueMessage<T>(message);
1289
+ if (!completable) {
1290
+ return queueMessage;
1291
+ }
1292
+
1293
+ if (opts?.replay && opts.completed) {
1294
+ return {
1295
+ ...queueMessage,
1296
+ complete: async () => {
1297
+ // No-op: this message was already completed in a prior run.
1298
+ },
1299
+ };
1300
+ }
1301
+
1302
+ const messageId = message.id;
1303
+ this.pendingCompletableMessageIds.add(messageId);
1304
+ let completed = false;
1305
+
1306
+ return {
1307
+ ...queueMessage,
1308
+ complete: async (response?: unknown) => {
1309
+ if (completed) {
1310
+ throw new Error("Queue message already completed");
1311
+ }
1312
+ completed = true;
1313
+ try {
1314
+ await this.completeMessage(message, response);
1315
+ await this.markQueueMessageCompleted(opts?.historyLocation);
1316
+ this.pendingCompletableMessageIds.delete(messageId);
1317
+ } catch (error) {
1318
+ completed = false;
1319
+ throw error;
1320
+ }
1321
+ },
1322
+ };
1323
+ }
1324
+
1325
+ private async markQueueMessageCompleted(
1326
+ historyLocation: Location | undefined,
1327
+ ): Promise<void> {
1328
+ if (!historyLocation) {
1329
+ return;
1330
+ }
1331
+ const key = locationToKey(this.storage, historyLocation);
1332
+ const entry = this.storage.history.entries.get(key);
1333
+ if (!entry || entry.kind.type !== "message") {
1334
+ return;
1335
+ }
1336
+ const parsed = this.fromHistoryQueueMessage(
1337
+ entry.kind.data.name,
1338
+ entry.kind.data.data,
1339
+ );
1340
+ entry.kind.data.data = this.toHistoryQueueMessage(
1341
+ this.toWorkflowQueueMessage(parsed.message),
1342
+ true,
1343
+ );
1344
+ entry.dirty = true;
1345
+ await this.flushStorage();
1346
+ }
1347
+
1348
+ private async completeMessage(
1349
+ message: Message,
1350
+ response?: unknown,
1351
+ ): Promise<void> {
1352
+ if (message.complete) {
1353
+ await message.complete(response);
1354
+ return;
1355
+ }
1356
+ await this.messageDriver.completeMessage(message.id, response);
1357
+ }
1358
+
1359
+ private toHistoryQueueMessage(
1360
+ message: WorkflowQueueMessage<unknown>,
1361
+ completed = false,
1362
+ ): unknown {
1363
+ return {
1364
+ [QUEUE_HISTORY_MESSAGE_MARKER]: 1,
1365
+ id: message.id,
1366
+ name: message.name,
1367
+ body: message.body,
1368
+ createdAt: message.createdAt,
1369
+ completed,
1370
+ };
1371
+ }
1372
+
1373
+ private fromHistoryQueueMessage(
1374
+ name: string,
1375
+ value: unknown,
1376
+ ): { message: Message; completed: boolean } {
1377
+ if (
1378
+ typeof value === "object" &&
1379
+ value !== null &&
1380
+ (value as Record<string, unknown>)[QUEUE_HISTORY_MESSAGE_MARKER] === 1
1381
+ ) {
1382
+ const serialized = value as Record<string, unknown>;
1383
+ const id =
1384
+ typeof serialized.id === "string" ? serialized.id : "";
1385
+ const serializedName =
1386
+ typeof serialized.name === "string" ? serialized.name : name;
1387
+ const createdAt =
1388
+ typeof serialized.createdAt === "number" ? serialized.createdAt : 0;
1389
+ const completed =
1390
+ typeof serialized.completed === "boolean"
1391
+ ? serialized.completed
1392
+ : false;
1393
+ return {
1394
+ message: {
1395
+ id,
1396
+ name: serializedName,
1397
+ data: serialized.body,
1398
+ sentAt: createdAt,
1399
+ },
1400
+ completed,
1401
+ };
1402
+ }
1403
+ return {
1404
+ message: {
1405
+ id: "",
1406
+ name,
1407
+ data: value,
1408
+ sentAt: 0,
1409
+ },
1410
+ completed: false,
1411
+ };
1412
+ }
1413
+
1414
+ // === Join ===
1415
+
1416
+ async join<T extends Record<string, BranchConfig<unknown>>>(
1417
+ name: string,
1418
+ branches: T,
1419
+ ): Promise<{ [K in keyof T]: BranchOutput<T[K]> }> {
1420
+ this.assertNotInProgress();
1421
+ this.checkEvicted();
1422
+
1423
+ this.entryInProgress = true;
1424
+ try {
1425
+ return await this.executeJoin(name, branches);
1426
+ } finally {
1427
+ this.entryInProgress = false;
1428
+ }
1429
+ }
1430
+
1431
+ private async executeJoin<T extends Record<string, BranchConfig<unknown>>>(
1432
+ name: string,
1433
+ branches: T,
1434
+ ): Promise<{ [K in keyof T]: BranchOutput<T[K]> }> {
1435
+ // Check for duplicate name in current execution
1436
+ this.checkDuplicateName(name);
1437
+
1438
+ const location = appendName(this.storage, this.currentLocation, name);
1439
+ const key = locationToKey(this.storage, location);
1440
+ const existing = this.storage.history.entries.get(key);
1441
+
1442
+ // Mark this entry as visited for validateComplete
1443
+ this.markVisited(key);
1444
+
1445
+ this.stopRollbackIfMissing(existing);
1446
+
1447
+ let entry: Entry;
1448
+
1449
+ if (existing) {
1450
+ if (existing.kind.type !== "join") {
1451
+ throw new HistoryDivergedError(
1452
+ `Expected join "${name}" at ${key}, found ${existing.kind.type}`,
1453
+ );
1454
+ }
1455
+ entry = existing;
1456
+ } else {
1457
+ entry = createEntry(location, {
1458
+ type: "join",
1459
+ data: {
1460
+ branches: Object.fromEntries(
1461
+ Object.keys(branches).map((k) => [
1462
+ k,
1463
+ { status: "pending" as const },
1464
+ ]),
1465
+ ),
1466
+ },
1467
+ });
1468
+ setEntry(this.storage, location, entry);
1469
+ entry.dirty = true;
1470
+ // Flush immediately to persist entry before branches execute
1471
+ await this.flushStorage();
1472
+ }
1473
+
1474
+ if (entry.kind.type !== "join") {
1475
+ throw new HistoryDivergedError("Entry type mismatch");
1476
+ }
1477
+
1478
+ this.stopRollbackIfIncomplete(
1479
+ Object.values(entry.kind.data.branches).some(
1480
+ (branch) => branch.status !== "completed",
1481
+ ),
1482
+ );
1483
+
1484
+ const joinData = entry.kind.data;
1485
+ const results: Record<string, unknown> = {};
1486
+ const errors: Record<string, Error> = {};
1487
+
1488
+ // Execute all branches in parallel
1489
+ const branchPromises = Object.entries(branches).map(
1490
+ async ([branchName, config]) => {
1491
+ const branchStatus = joinData.branches[branchName];
1492
+
1493
+ // Already completed
1494
+ if (branchStatus.status === "completed") {
1495
+ results[branchName] = branchStatus.output;
1496
+ return;
1497
+ }
1498
+
1499
+ // Already failed
1500
+ if (branchStatus.status === "failed") {
1501
+ errors[branchName] = new Error(branchStatus.error);
1502
+ return;
1503
+ }
1504
+
1505
+ // Execute branch
1506
+ const branchLocation = appendName(
1507
+ this.storage,
1508
+ location,
1509
+ branchName,
1510
+ );
1511
+ const branchCtx = this.createBranch(branchLocation);
1512
+
1513
+ branchStatus.status = "running";
1514
+ entry.dirty = true;
1515
+
1516
+ try {
1517
+ const output = await config.run(branchCtx);
1518
+ branchCtx.validateComplete();
1519
+
1520
+ branchStatus.status = "completed";
1521
+ branchStatus.output = output;
1522
+ results[branchName] = output;
1523
+ } catch (error) {
1524
+ branchStatus.status = "failed";
1525
+ branchStatus.error = String(error);
1526
+ errors[branchName] = error as Error;
1527
+ }
1528
+
1529
+ entry.dirty = true;
1530
+ },
1531
+ );
1532
+
1533
+ // Wait for ALL branches (no short-circuit on error)
1534
+ await Promise.allSettled(branchPromises);
1535
+ await this.flushStorage();
1536
+
1537
+ // Throw if any branches failed
1538
+ if (Object.keys(errors).length > 0) {
1539
+ throw new JoinError(errors);
1540
+ }
1541
+
1542
+ return results as { [K in keyof T]: BranchOutput<T[K]> };
1543
+ }
1544
+
1545
+ // === Race ===
1546
+
1547
+ async race<T>(
1548
+ name: string,
1549
+ branches: Array<{
1550
+ name: string;
1551
+ run: (ctx: WorkflowContextInterface) => Promise<T>;
1552
+ }>,
1553
+ ): Promise<{ winner: string; value: T }> {
1554
+ this.assertNotInProgress();
1555
+ this.checkEvicted();
1556
+
1557
+ this.entryInProgress = true;
1558
+ try {
1559
+ return await this.executeRace(name, branches);
1560
+ } finally {
1561
+ this.entryInProgress = false;
1562
+ }
1563
+ }
1564
+
1565
+ private async executeRace<T>(
1566
+ name: string,
1567
+ branches: Array<{
1568
+ name: string;
1569
+ run: (ctx: WorkflowContextInterface) => Promise<T>;
1570
+ }>,
1571
+ ): Promise<{ winner: string; value: T }> {
1572
+ // Check for duplicate name in current execution
1573
+ this.checkDuplicateName(name);
1574
+
1575
+ const location = appendName(this.storage, this.currentLocation, name);
1576
+ const key = locationToKey(this.storage, location);
1577
+ const existing = this.storage.history.entries.get(key);
1578
+
1579
+ // Mark this entry as visited for validateComplete
1580
+ this.markVisited(key);
1581
+
1582
+ this.stopRollbackIfMissing(existing);
1583
+
1584
+ let entry: Entry;
1585
+
1586
+ if (existing) {
1587
+ if (existing.kind.type !== "race") {
1588
+ throw new HistoryDivergedError(
1589
+ `Expected race "${name}" at ${key}, found ${existing.kind.type}`,
1590
+ );
1591
+ }
1592
+ entry = existing;
1593
+
1594
+ // Check if we already have a winner
1595
+ const raceKind = existing.kind;
1596
+ if (raceKind.data.winner !== null) {
1597
+ const winnerStatus =
1598
+ raceKind.data.branches[raceKind.data.winner];
1599
+ return {
1600
+ winner: raceKind.data.winner,
1601
+ value: winnerStatus.output as T,
1602
+ };
1603
+ }
1604
+
1605
+ this.stopRollbackIfIncomplete(true);
1606
+ } else {
1607
+ entry = createEntry(location, {
1608
+ type: "race",
1609
+ data: {
1610
+ winner: null,
1611
+ branches: Object.fromEntries(
1612
+ branches.map((b) => [
1613
+ b.name,
1614
+ { status: "pending" as const },
1615
+ ]),
1616
+ ),
1617
+ },
1618
+ });
1619
+ setEntry(this.storage, location, entry);
1620
+ entry.dirty = true;
1621
+ // Flush immediately to persist entry before branches execute
1622
+ await this.flushStorage();
1623
+ }
1624
+
1625
+ if (entry.kind.type !== "race") {
1626
+ throw new HistoryDivergedError("Entry type mismatch");
1627
+ }
1628
+
1629
+ const raceData = entry.kind.data;
1630
+
1631
+ // Create abort controller for cancellation
1632
+ const raceAbortController = new AbortController();
1633
+
1634
+ // Track all branch promises to wait for cleanup
1635
+ const branchPromises: Promise<void>[] = [];
1636
+
1637
+ // Track winner info
1638
+ let winnerName: string | null = null;
1639
+ let winnerValue: T | null = null;
1640
+ let settled = false;
1641
+ let pendingCount = branches.length;
1642
+ const errors: Record<string, Error> = {};
1643
+ const lateErrors: Array<{ name: string; error: string }> = [];
1644
+ // Track scheduler yield errors - we need to propagate these after allSettled
1645
+ let yieldError: SleepError | MessageWaitError | null = null;
1646
+
1647
+ // Check for replay winners first
1648
+ for (const branch of branches) {
1649
+ const branchStatus = raceData.branches[branch.name];
1650
+ if (
1651
+ branchStatus.status !== "pending" &&
1652
+ branchStatus.status !== "running"
1653
+ ) {
1654
+ pendingCount--;
1655
+ if (branchStatus.status === "completed" && !settled) {
1656
+ settled = true;
1657
+ winnerName = branch.name;
1658
+ winnerValue = branchStatus.output as T;
1659
+ }
1660
+ }
1661
+ }
1662
+
1663
+ // If we found a replay winner, return immediately
1664
+ if (settled && winnerName !== null && winnerValue !== null) {
1665
+ return { winner: winnerName, value: winnerValue };
1666
+ }
1667
+
1668
+ // Execute branches that need to run
1669
+ for (const branch of branches) {
1670
+ const branchStatus = raceData.branches[branch.name];
1671
+
1672
+ // Skip already completed/cancelled
1673
+ if (
1674
+ branchStatus.status !== "pending" &&
1675
+ branchStatus.status !== "running"
1676
+ ) {
1677
+ continue;
1678
+ }
1679
+
1680
+ const branchLocation = appendName(
1681
+ this.storage,
1682
+ location,
1683
+ branch.name,
1684
+ );
1685
+ const branchCtx = this.createBranch(
1686
+ branchLocation,
1687
+ raceAbortController,
1688
+ );
1689
+
1690
+ branchStatus.status = "running";
1691
+ entry.dirty = true;
1692
+
1693
+ const branchPromise = branch.run(branchCtx).then(
1694
+ async (output) => {
1695
+ if (settled) {
1696
+ // This branch completed after a winner was determined
1697
+ // Still record the completion for observability
1698
+ branchStatus.status = "completed";
1699
+ branchStatus.output = output;
1700
+ entry.dirty = true;
1701
+ return;
1702
+ }
1703
+ settled = true;
1704
+ winnerName = branch.name;
1705
+ winnerValue = output;
1706
+
1707
+ branchCtx.validateComplete();
1708
+
1709
+ branchStatus.status = "completed";
1710
+ branchStatus.output = output;
1711
+ raceData.winner = branch.name;
1712
+ entry.dirty = true;
1713
+
1714
+ // Cancel other branches
1715
+ raceAbortController.abort();
1716
+ },
1717
+ (error) => {
1718
+ pendingCount--;
1719
+
1720
+ // Track sleep/message errors - they need to bubble up to the scheduler
1721
+ // We'll re-throw after allSettled to allow cleanup
1722
+ if (error instanceof SleepError) {
1723
+ // Track the earliest deadline
1724
+ if (
1725
+ !yieldError ||
1726
+ !(yieldError instanceof SleepError) ||
1727
+ error.deadline < yieldError.deadline
1728
+ ) {
1729
+ yieldError = error;
1730
+ }
1731
+ branchStatus.status = "running"; // Keep as running since we'll resume
1732
+ entry.dirty = true;
1733
+ return;
1734
+ }
1735
+ if (error instanceof MessageWaitError) {
1736
+ // Track message wait errors, prefer sleep errors with deadlines
1737
+ if (!yieldError || !(yieldError instanceof SleepError)) {
1738
+ if (!yieldError) {
1739
+ yieldError = error;
1740
+ } else if (yieldError instanceof MessageWaitError) {
1741
+ // Merge message names
1742
+ yieldError = new MessageWaitError([
1743
+ ...yieldError.messageNames,
1744
+ ...error.messageNames,
1745
+ ]);
1746
+ }
1747
+ }
1748
+ branchStatus.status = "running"; // Keep as running since we'll resume
1749
+ entry.dirty = true;
1750
+ return;
1751
+ }
1752
+
1753
+ if (
1754
+ error instanceof CancelledError ||
1755
+ error instanceof EvictedError
1756
+ ) {
1757
+ branchStatus.status = "cancelled";
1758
+ } else {
1759
+ branchStatus.status = "failed";
1760
+ branchStatus.error = String(error);
1761
+
1762
+ if (settled) {
1763
+ // Track late errors for observability
1764
+ lateErrors.push({
1765
+ name: branch.name,
1766
+ error: String(error),
1767
+ });
1768
+ } else {
1769
+ errors[branch.name] = error;
1770
+ }
1771
+ }
1772
+ entry.dirty = true;
1773
+
1774
+ // All branches failed (only if no winner yet)
1775
+ if (pendingCount === 0 && !settled) {
1776
+ settled = true;
1777
+ }
1778
+ },
1779
+ );
1780
+
1781
+ branchPromises.push(branchPromise);
1782
+ }
1783
+
1784
+ // Wait for all branches to complete or be cancelled
1785
+ await Promise.allSettled(branchPromises);
1786
+
1787
+ // If any branch needs to yield to the scheduler (sleep/message wait),
1788
+ // save state and re-throw the error to exit the workflow execution
1789
+ if (yieldError && !settled) {
1790
+ await this.flushStorage();
1791
+ throw yieldError;
1792
+ }
1793
+
1794
+ // Clean up entries from non-winning branches
1795
+ if (winnerName !== null) {
1796
+ for (const branch of branches) {
1797
+ if (branch.name !== winnerName) {
1798
+ const branchLocation = appendName(
1799
+ this.storage,
1800
+ location,
1801
+ branch.name,
1802
+ );
1803
+ await deleteEntriesWithPrefix(
1804
+ this.storage,
1805
+ this.driver,
1806
+ branchLocation,
1807
+ this.historyNotifier,
1808
+ );
1809
+ }
1810
+ }
1811
+ }
1812
+
1813
+ // Flush final state
1814
+ await this.flushStorage();
1815
+
1816
+ // Log late errors if any (these occurred after a winner was determined)
1817
+ if (lateErrors.length > 0) {
1818
+ console.warn(
1819
+ `Race "${name}" had ${lateErrors.length} branch(es) fail after winner was determined:`,
1820
+ lateErrors,
1821
+ );
1822
+ }
1823
+
1824
+ // Return result or throw error
1825
+ if (winnerName !== null && winnerValue !== null) {
1826
+ return { winner: winnerName, value: winnerValue };
1827
+ }
1828
+
1829
+ // All branches failed
1830
+ throw new RaceError(
1831
+ "All branches failed",
1832
+ Object.entries(errors).map(([name, error]) => ({
1833
+ name,
1834
+ error: String(error),
1835
+ })),
1836
+ );
1837
+ }
1838
+
1839
+ // === Removed ===
1840
+
1841
+ async removed(name: string, originalType: EntryKindType): Promise<void> {
1842
+ this.assertNotInProgress();
1843
+ this.checkEvicted();
1844
+
1845
+ this.entryInProgress = true;
1846
+ try {
1847
+ await this.executeRemoved(name, originalType);
1848
+ } finally {
1849
+ this.entryInProgress = false;
1850
+ }
1851
+ }
1852
+
1853
+ private async executeRemoved(
1854
+ name: string,
1855
+ originalType: EntryKindType,
1856
+ ): Promise<void> {
1857
+ // Check for duplicate name in current execution
1858
+ this.checkDuplicateName(name);
1859
+
1860
+ const location = appendName(this.storage, this.currentLocation, name);
1861
+ const key = locationToKey(this.storage, location);
1862
+ const existing = this.storage.history.entries.get(key);
1863
+
1864
+ // Mark this entry as visited for validateComplete
1865
+ this.markVisited(key);
1866
+
1867
+ this.stopRollbackIfMissing(existing);
1868
+
1869
+ if (existing) {
1870
+ // Validate the existing entry matches what we expect
1871
+ if (
1872
+ existing.kind.type !== "removed" &&
1873
+ existing.kind.type !== originalType
1874
+ ) {
1875
+ throw new HistoryDivergedError(
1876
+ `Expected ${originalType} or removed at ${key}, found ${existing.kind.type}`,
1877
+ );
1878
+ }
1879
+
1880
+ // If it's not already marked as removed, we just skip it
1881
+ return;
1882
+ }
1883
+
1884
+ // Create a removed entry placeholder
1885
+ const entry = createEntry(location, {
1886
+ type: "removed",
1887
+ data: { originalType, originalName: name },
1888
+ });
1889
+ setEntry(this.storage, location, entry);
1890
+ await this.flushStorage();
1891
+ }
1892
+ }