workerflow 0.1.0 → 0.2.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.
@@ -3,6 +3,8 @@ import { env } from "cloudflare:workers";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  WorkflowRuntimeContext,
6
+ type RunStep,
7
+ type RunStepAttempt,
6
8
  type RunStepId,
7
9
  type SleepStepId,
8
10
  type WaitStepId,
@@ -98,12 +100,14 @@ describe("WorkflowRuntime", () => {
98
100
  await expect(promise).resolves.toBe("failed");
99
101
  const steps = instance.getSteps_experimental();
100
102
  expect(steps).toHaveLength(1);
101
- expect(steps[0]).toMatchObject({
102
- type: "run",
103
+ const step = steps[0]!;
104
+ expect(step.type).toBe("run");
105
+ const attempts = (step as RunStep & { attempts: RunStepAttempt[] }).attempts;
106
+ expect(attempts).toHaveLength(1);
107
+ expect(attempts[0]).toMatchObject({
103
108
  state: "failed",
104
109
  errorMessage: "NonRetryableStepError: This is a non-retryable step error",
105
- errorName: "NonRetryableStepError",
106
- attemptCount: 1
110
+ errorName: "NonRetryableStepError"
107
111
  });
108
112
  });
109
113
  } finally {
@@ -136,12 +140,14 @@ describe("WorkflowRuntime", () => {
136
140
  await expect(promise).resolves.toBe("failed");
137
141
  const steps = instance.getSteps_experimental();
138
142
  expect(steps).toHaveLength(1);
139
- expect(steps[0]).toMatchObject({
140
- type: "run",
143
+ const step = steps[0]!;
144
+ expect(step.type).toBe("run");
145
+ const attempts = (step as RunStep & { attempts: RunStepAttempt[] }).attempts;
146
+ expect(attempts).toHaveLength(2);
147
+ expect(attempts[1]).toMatchObject({
141
148
  state: "failed",
142
149
  errorMessage: "Error: test",
143
- errorName: "Error",
144
- attemptCount: 2
150
+ errorName: "Error"
145
151
  });
146
152
  });
147
153
  } finally {
@@ -198,11 +204,12 @@ describe("WorkflowRuntime", () => {
198
204
  await expect(promise).resolves.toBe("completed");
199
205
  const steps = instance.getSteps_experimental();
200
206
  expect(steps).toHaveLength(1);
201
- expect(steps[0]).toMatchObject({
202
- type: "run",
203
- attemptCount: 2,
204
- state: "succeeded"
205
- });
207
+ const step = steps[0]!;
208
+ expect(step.type).toBe("run");
209
+ const attempts = (step as RunStep & { attempts: RunStepAttempt[] }).attempts;
210
+ expect(attempts).toHaveLength(2);
211
+ expect(attempts[0]).toMatchObject({ state: "failed" });
212
+ expect(attempts[1]).toMatchObject({ state: "succeeded" });
206
213
  // First next(): failed attempt yields suspended. Retry alarm: second next() replays `execute()` and completes
207
214
  // the successful attempt in the same invocation (no extra immediate loop).
208
215
  expect(nextSpy).toHaveBeenCalledTimes(2);
@@ -266,8 +273,16 @@ describe("WorkflowRuntime", () => {
266
273
 
267
274
  const steps = instance.getSteps_experimental();
268
275
  expect(steps).toHaveLength(2);
269
- expect(steps[0]).toMatchObject({ id: "step-a", type: "run", state: "succeeded" });
270
- expect(steps[1]).toMatchObject({ id: "step-b", type: "run", state: "succeeded" });
276
+ const firstStep = steps[0]!;
277
+ expect(firstStep.id).toBe("step-a");
278
+ expect(firstStep.type).toBe("run");
279
+ const firstAttempts = (firstStep as RunStep & { attempts: RunStepAttempt[] }).attempts;
280
+ expect(firstAttempts[firstAttempts.length - 1]).toMatchObject({ state: "succeeded" });
281
+ const secondStep = steps[1]!;
282
+ expect(secondStep.id).toBe("step-b");
283
+ expect(secondStep.type).toBe("run");
284
+ const secondAttempts = (secondStep as RunStep & { attempts: RunStepAttempt[] }).attempts;
285
+ expect(secondAttempts[secondAttempts.length - 1]).toMatchObject({ state: "succeeded" });
271
286
 
272
287
  expect(nextSpy).toHaveBeenCalledTimes(2);
273
288
  });
@@ -300,7 +315,10 @@ describe("WorkflowRuntime", () => {
300
315
  await expect(promise).resolves.toBe("completed");
301
316
 
302
317
  const steps = instance.getSteps_experimental();
303
- expect(steps.find((s) => s.id === "before-sleep")).toMatchObject({ type: "run", state: "succeeded" });
318
+ const before = steps.find((s) => s.id === "before-sleep");
319
+ expect(before?.type).toBe("run");
320
+ const beforeAttempts = (before as RunStep & { attempts: RunStepAttempt[] }).attempts;
321
+ expect(beforeAttempts[beforeAttempts.length - 1]).toMatchObject({ state: "succeeded" });
304
322
  expect(steps.find((s) => s.id === "sleep-after-run")).toMatchObject({
305
323
  type: "sleep",
306
324
  state: "elapsed",
@@ -414,26 +432,16 @@ describe("WorkflowRuntime", () => {
414
432
  await expect(promise).resolves.toBe("completed");
415
433
 
416
434
  const steps = instance.getSteps_experimental();
417
- expect(steps.find((s) => s.id === "L0")).toMatchObject({
418
- type: "run",
419
- parentStepId: null,
420
- state: "succeeded"
421
- });
422
- expect(steps.find((s) => s.id === "L1")).toMatchObject({
423
- type: "run",
424
- parentStepId: "L0",
425
- state: "succeeded"
426
- });
427
- expect(steps.find((s) => s.id === "L2")).toMatchObject({
428
- type: "run",
429
- parentStepId: "L1",
430
- state: "succeeded"
431
- });
432
-
433
- const events = await instance.getStepEvents_experimental();
434
- for (const id of ["L0", "L1"] as const) {
435
- expect(events.filter((event) => event.stepId === id && event.type === "attempt_failed")).toHaveLength(0);
435
+ for (const id of ["L0", "L1", "L2"] as const) {
436
+ const row = steps.find((s) => s.id === id);
437
+ expect(row?.type).toBe("run");
438
+ const attempts = (row as RunStep & { attempts: RunStepAttempt[] }).attempts;
439
+ expect(attempts.filter((a) => a.state === "failed")).toHaveLength(0);
440
+ expect(attempts[attempts.length - 1]).toMatchObject({ state: "succeeded" });
436
441
  }
442
+ expect(steps.find((s) => s.id === "L0")).toMatchObject({ parentStepId: null });
443
+ expect(steps.find((s) => s.id === "L1")).toMatchObject({ parentStepId: "L0" });
444
+ expect(steps.find((s) => s.id === "L2")).toMatchObject({ parentStepId: "L1" });
437
445
  });
438
446
  } finally {
439
447
  executeSpy.mockRestore();
@@ -462,11 +470,11 @@ describe("WorkflowRuntime", () => {
462
470
  await expect(promise).resolves.toBe("completed");
463
471
 
464
472
  const steps = instance.getSteps_experimental();
465
- expect(steps.find((s) => s.id === "root-after")).toMatchObject({
466
- type: "run",
467
- parentStepId: null,
468
- state: "succeeded"
469
- });
473
+ const rootAfter = steps.find((s) => s.id === "root-after");
474
+ expect(rootAfter?.type).toBe("run");
475
+ expect(rootAfter).toMatchObject({ parentStepId: null });
476
+ const raa = (rootAfter as RunStep & { attempts: RunStepAttempt[] }).attempts;
477
+ expect(raa[raa.length - 1]).toMatchObject({ state: "succeeded" });
470
478
  expect(steps.find((s) => s.id === "nest-inner")).toMatchObject({
471
479
  parentStepId: "nest-outer"
472
480
  });
@@ -504,30 +512,15 @@ describe("WorkflowRuntime", () => {
504
512
  await expect(promise).resolves.toBe("completed");
505
513
 
506
514
  const steps = instance.getSteps_experimental();
507
- expect(steps.find((s) => s.id === "branch-a")).toMatchObject({
508
- type: "run",
509
- parentStepId: null,
510
- state: "succeeded"
511
- });
512
- expect(steps.find((s) => s.id === "branch-a-inner")).toMatchObject({
513
- type: "run",
514
- parentStepId: "branch-a",
515
- state: "succeeded"
516
- });
517
- expect(steps.find((s) => s.id === "branch-b")).toMatchObject({
518
- type: "run",
519
- parentStepId: null,
520
- state: "succeeded"
521
- });
522
- expect(steps.find((s) => s.id === "branch-b-inner")).toMatchObject({
523
- type: "run",
524
- parentStepId: "branch-b",
525
- state: "succeeded"
526
- });
527
-
528
- const events = await instance.getStepEvents_experimental();
529
- expect(events.filter((event) => event.stepId === "branch-a" && event.type === "attempt_failed")).toHaveLength(0);
530
- expect(events.filter((event) => event.stepId === "branch-b" && event.type === "attempt_failed")).toHaveLength(0);
515
+ for (const id of ["branch-a", "branch-a-inner", "branch-b", "branch-b-inner"] as const) {
516
+ const row = steps.find((s) => s.id === id);
517
+ expect(row?.type).toBe("run");
518
+ const attempts = (row as RunStep & { attempts: RunStepAttempt[] }).attempts;
519
+ expect(attempts.filter((a) => a.state === "failed")).toHaveLength(0);
520
+ expect(attempts[attempts.length - 1]).toMatchObject({ state: "succeeded" });
521
+ }
522
+ expect(steps.find((s) => s.id === "branch-a-inner")).toMatchObject({ parentStepId: "branch-a" });
523
+ expect(steps.find((s) => s.id === "branch-b-inner")).toMatchObject({ parentStepId: "branch-b" });
531
524
  });
532
525
  } finally {
533
526
  executeSpy.mockRestore();
@@ -560,11 +553,11 @@ describe("WorkflowRuntime", () => {
560
553
  await expect(promise).resolves.toBe("completed");
561
554
 
562
555
  const steps = instance.getSteps_experimental();
563
- expect(steps.find((s) => s.id === "nested-branch-inner")).toMatchObject({
564
- type: "run",
565
- parentStepId: "nested-branch",
566
- state: "succeeded"
567
- });
556
+ const nbInner = steps.find((s) => s.id === "nested-branch-inner");
557
+ expect(nbInner?.type).toBe("run");
558
+ expect(nbInner).toMatchObject({ parentStepId: "nested-branch" });
559
+ const nbia = (nbInner as RunStep & { attempts: RunStepAttempt[] }).attempts;
560
+ expect(nbia[nbia.length - 1]).toMatchObject({ state: "succeeded" });
568
561
  expect(steps.find((s) => s.id === "parallel-wait-nested")).toMatchObject({
569
562
  type: "wait",
570
563
  state: "waiting"
@@ -629,7 +622,10 @@ describe("WorkflowRuntime", () => {
629
622
  await instance.create({ definitionVersion: "2026-03-19" });
630
623
 
631
624
  await expect
632
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "deep-wait")?.state)
625
+ .poll(() => {
626
+ const step = instance.getSteps_experimental().find((s) => s.id === "deep-wait");
627
+ return step?.type === "wait" ? step.state : undefined;
628
+ })
633
629
  .toBe("waiting");
634
630
 
635
631
  const stepsWaiting = instance.getSteps_experimental();
@@ -646,12 +642,12 @@ describe("WorkflowRuntime", () => {
646
642
  type: "wait",
647
643
  parentStepId: "outer-wait",
648
644
  state: "satisfied",
649
- payload: JSON.stringify({ ok: true })
650
- });
651
- expect(instance.getSteps_experimental().find((s) => s.id === "outer-wait")).toMatchObject({
652
- type: "run",
653
- state: "succeeded"
645
+ payload: { ok: true }
654
646
  });
647
+ const outerWait = instance.getSteps_experimental().find((s) => s.id === "outer-wait");
648
+ expect(outerWait?.type).toBe("run");
649
+ const owa = (outerWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
650
+ expect(owa[owa.length - 1]).toMatchObject({ state: "succeeded" });
655
651
  });
656
652
  } finally {
657
653
  executeSpy.mockRestore();
@@ -681,16 +677,14 @@ describe("WorkflowRuntime", () => {
681
677
  await expect(promise).resolves.toBe("failed");
682
678
 
683
679
  const steps = instance.getSteps_experimental();
684
- expect(steps.find((s) => s.id === "fail-inner")).toMatchObject({
685
- type: "run",
686
- state: "failed",
687
- errorName: "NonRetryableStepError"
688
- });
689
- expect(steps.find((s) => s.id === "fail-outer")).toMatchObject({
690
- type: "run",
691
- state: "failed",
692
- errorName: "NonRetryableStepError"
693
- });
680
+ const fi = steps.find((s) => s.id === "fail-inner");
681
+ expect(fi?.type).toBe("run");
682
+ const fia = (fi as RunStep & { attempts: RunStepAttempt[] }).attempts;
683
+ expect(fia[fia.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
684
+ const fo = steps.find((s) => s.id === "fail-outer");
685
+ expect(fo?.type).toBe("run");
686
+ const foa = (fo as RunStep & { attempts: RunStepAttempt[] }).attempts;
687
+ expect(foa[foa.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
694
688
  });
695
689
  } finally {
696
690
  executeSpy.mockRestore();
@@ -730,16 +724,15 @@ describe("WorkflowRuntime", () => {
730
724
  await expect(promise).resolves.toBe("completed");
731
725
 
732
726
  expect(innerAttempts).toBe(2);
733
- const events = await instance.getStepEvents_experimental();
734
- expect(events.filter((event) => event.stepId === "suspend-outer" && event.type === "attempt_failed")).toHaveLength(0);
735
- expect(instance.getSteps_experimental().find((s) => s.id === "suspend-outer")).toMatchObject({
736
- type: "run",
737
- state: "succeeded"
738
- });
739
- expect(instance.getSteps_experimental().find((s) => s.id === "suspend-inner")).toMatchObject({
740
- type: "run",
741
- state: "succeeded"
742
- });
727
+ const outerRow = instance.getSteps_experimental().find((s) => s.id === "suspend-outer");
728
+ expect(outerRow?.type).toBe("run");
729
+ const oa = (outerRow as RunStep & { attempts: RunStepAttempt[] }).attempts;
730
+ expect(oa.filter((a) => a.state === "failed")).toHaveLength(0);
731
+ expect(oa[oa.length - 1]).toMatchObject({ state: "succeeded" });
732
+ const innerRow = instance.getSteps_experimental().find((s) => s.id === "suspend-inner");
733
+ expect(innerRow?.type).toBe("run");
734
+ const ia = (innerRow as RunStep & { attempts: RunStepAttempt[] }).attempts;
735
+ expect(ia[ia.length - 1]).toMatchObject({ state: "succeeded" });
743
736
  });
744
737
  } finally {
745
738
  executeSpy.mockRestore();
@@ -773,17 +766,20 @@ describe("WorkflowRuntime", () => {
773
766
  await expect(promise).resolves.toBe("failed");
774
767
 
775
768
  const steps = instance.getSteps_experimental();
776
- expect(steps.find((s) => s.id === "ex-inner")).toMatchObject({
777
- type: "run",
769
+ const exInner = steps.find((s) => s.id === "ex-inner");
770
+ expect(exInner?.type).toBe("run");
771
+ const exIa = (exInner as RunStep & { attempts: RunStepAttempt[] }).attempts;
772
+ expect(exIa[exIa.length - 1]).toMatchObject({
778
773
  state: "failed",
779
774
  errorName: "Error",
780
775
  errorMessage: "Error: always fail"
781
776
  });
782
- expect(steps.find((s) => s.id === "ex-outer")).toMatchObject({
783
- type: "run",
777
+ const exOuter = steps.find((s) => s.id === "ex-outer");
778
+ expect(exOuter?.type).toBe("run");
779
+ const exOa = (exOuter as RunStep & { attempts: RunStepAttempt[] }).attempts;
780
+ expect(exOa[exOa.length - 1]).toMatchObject({
784
781
  state: "failed",
785
- errorName: "Error",
786
- errorMessage: "Error"
782
+ errorName: "Error"
787
783
  });
788
784
  });
789
785
  } finally {
@@ -813,12 +809,14 @@ describe("WorkflowRuntime", () => {
813
809
  await expect(promise).resolves.toBe("failed");
814
810
 
815
811
  const steps = instance.getSteps_experimental();
816
- expect(steps.find((s) => s.id === "post-inner")).toMatchObject({
817
- type: "run",
818
- state: "succeeded"
819
- });
820
- expect(steps.find((s) => s.id === "post-outer")).toMatchObject({
821
- type: "run",
812
+ const pi = steps.find((s) => s.id === "post-inner");
813
+ expect(pi?.type).toBe("run");
814
+ const pia = (pi as RunStep & { attempts: RunStepAttempt[] }).attempts;
815
+ expect(pia[pia.length - 1]).toMatchObject({ state: "succeeded" });
816
+ const po = steps.find((s) => s.id === "post-outer");
817
+ expect(po?.type).toBe("run");
818
+ const poa = (po as RunStep & { attempts: RunStepAttempt[] }).attempts;
819
+ expect(poa[poa.length - 1]).toMatchObject({
822
820
  state: "failed",
823
821
  errorMessage: expect.stringContaining("outer-only failure")
824
822
  });
@@ -849,21 +847,25 @@ describe("WorkflowRuntime", () => {
849
847
 
850
848
  await instance.create({ definitionVersion: "2026-03-19" });
851
849
  await expect
852
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "root-deep-wait")?.state)
850
+ .poll(() => {
851
+ const step = instance.getSteps_experimental().find((s) => s.id === "root-deep-wait");
852
+ return step?.type === "wait" ? step.state : undefined;
853
+ })
853
854
  .toBe("waiting");
854
855
 
855
- const eventsBefore = await instance.getStepEvents_experimental();
856
- expect(
857
- eventsBefore.filter((event) => event.stepId === "root-wait-run" && event.type === "attempt_failed")
858
- ).toHaveLength(0);
856
+ const rootRunBefore = instance.getSteps_experimental().find((s) => s.id === "root-wait-run");
857
+ expect(rootRunBefore?.type).toBe("run");
858
+ const rba = (rootRunBefore as RunStep & { attempts: RunStepAttempt[] }).attempts;
859
+ expect(rba.filter((a) => a.state === "failed")).toHaveLength(0);
859
860
 
860
861
  await instance.handleInboundEvent("root-deep-ev", true);
861
862
  await expect(promise).resolves.toBe("completed");
862
863
 
863
- const eventsAfter = await instance.getStepEvents_experimental();
864
- expect(
865
- eventsAfter.filter((event) => event.stepId === "root-wait-run" && event.type === "attempt_failed")
866
- ).toHaveLength(0);
864
+ const rootRunAfter = instance.getSteps_experimental().find((s) => s.id === "root-wait-run");
865
+ expect(rootRunAfter?.type).toBe("run");
866
+ const raa = (rootRunAfter as RunStep & { attempts: RunStepAttempt[] }).attempts;
867
+ expect(raa.filter((a) => a.state === "failed")).toHaveLength(0);
868
+ expect(raa[raa.length - 1]).toMatchObject({ state: "succeeded" });
867
869
  });
868
870
  } finally {
869
871
  executeSpy.mockRestore();
@@ -898,10 +900,10 @@ describe("WorkflowRuntime", () => {
898
900
 
899
901
  const steps = instance.getSteps_experimental();
900
902
  expect(steps).toHaveLength(2);
901
- expect(steps.find((s) => s.id === "parallel-run")).toMatchObject({
902
- type: "run",
903
- state: "succeeded"
904
- });
903
+ const prun = steps.find((s) => s.id === "parallel-run");
904
+ expect(prun?.type).toBe("run");
905
+ const pra = (prun as RunStep & { attempts: RunStepAttempt[] }).attempts;
906
+ expect(pra[pra.length - 1]).toMatchObject({ state: "succeeded" });
905
907
  expect(steps.find((s) => s.id === "parallel-wait")).toMatchObject({
906
908
  type: "wait",
907
909
  state: "waiting"
@@ -937,15 +939,14 @@ describe("WorkflowRuntime", () => {
937
939
 
938
940
  const steps = instance.getSteps_experimental();
939
941
  expect(steps).toHaveLength(2);
940
- expect(steps.find((s) => s.id === "parallel-fail")).toMatchObject({
941
- type: "run",
942
- state: "failed",
943
- errorName: "NonRetryableStepError"
944
- });
945
- expect(steps.find((s) => s.id === "parallel-ok")).toMatchObject({
946
- type: "run",
947
- state: "succeeded"
948
- });
942
+ const pf = steps.find((s) => s.id === "parallel-fail");
943
+ expect(pf?.type).toBe("run");
944
+ const pfa = (pf as RunStep & { attempts: RunStepAttempt[] }).attempts;
945
+ expect(pfa[pfa.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
946
+ const pok = steps.find((s) => s.id === "parallel-ok");
947
+ expect(pok?.type).toBe("run");
948
+ const poka = (pok as RunStep & { attempts: RunStepAttempt[] }).attempts;
949
+ expect(poka[poka.length - 1]).toMatchObject({ state: "succeeded" });
949
950
  });
950
951
  } finally {
951
952
  executeSpy.mockRestore();
@@ -982,7 +983,10 @@ describe("WorkflowRuntime", () => {
982
983
  expect(terminalStatuses).toHaveLength(0);
983
984
 
984
985
  await expect
985
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "allsettled-rerun-wait")?.state)
986
+ .poll(() => {
987
+ const step = instance.getSteps_experimental().find((s) => s.id === "allsettled-rerun-wait");
988
+ return step?.type === "wait" ? step.state : undefined;
989
+ })
986
990
  .toBe("waiting");
987
991
 
988
992
  const steps = instance.getSteps_experimental();
@@ -992,10 +996,10 @@ describe("WorkflowRuntime", () => {
992
996
  });
993
997
  // Unlike `Promise.all`, `allSettled` waits for every branch before returning, so the run can finish
994
998
  // durably before we rethrow the `wait()` rejection.
995
- expect(steps.find((s) => s.id === "allsettled-rerun-run")).toMatchObject({
996
- type: "run",
997
- state: "succeeded"
998
- });
999
+ const asRun = steps.find((s) => s.id === "allsettled-rerun-run");
1000
+ expect(asRun?.type).toBe("run");
1001
+ const asra = (asRun as RunStep & { attempts: RunStepAttempt[] }).attempts;
1002
+ expect(asra[asra.length - 1]).toMatchObject({ state: "succeeded" });
999
1003
  });
1000
1004
  } finally {
1001
1005
  executeSpy.mockRestore();
@@ -1029,7 +1033,10 @@ describe("WorkflowRuntime", () => {
1029
1033
  expect(terminalStatuses).toHaveLength(0);
1030
1034
 
1031
1035
  await expect
1032
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "parallel-wait")?.state)
1036
+ .poll(() => {
1037
+ const step = instance.getSteps_experimental().find((s) => s.id === "parallel-wait");
1038
+ return step?.type === "wait" ? step.state : undefined;
1039
+ })
1033
1040
  .toBe("waiting");
1034
1041
 
1035
1042
  const steps = instance.getSteps_experimental();
@@ -1037,11 +1044,11 @@ describe("WorkflowRuntime", () => {
1037
1044
  type: "wait",
1038
1045
  state: "waiting"
1039
1046
  });
1040
- // `wait()` usually wins the race; the run branch can still be mid-flight so the step may not yet be "succeeded".
1041
- expect(steps.find((s) => s.id === "parallel-run")).toMatchObject({
1042
- type: "run",
1043
- state: "running"
1044
- });
1047
+ // `wait()` usually wins the race; the run branch can still be mid-flight so the latest attempt may still be in flight.
1048
+ const parRun = steps.find((s) => s.id === "parallel-run");
1049
+ expect(parRun?.type).toBe("run");
1050
+ const paa = (parRun as RunStep & { attempts: RunStepAttempt[] }).attempts;
1051
+ expect(paa[paa.length - 1]).toMatchObject({ state: "started" });
1045
1052
  });
1046
1053
  } finally {
1047
1054
  executeSpy.mockRestore();
@@ -1113,7 +1120,10 @@ describe("WorkflowRuntime", () => {
1113
1120
  await instance.create({ definitionVersion: "2026-03-19" });
1114
1121
  await expect.poll(() => instance.getStatus()).toBe("running");
1115
1122
  await expect
1116
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1123
+ .poll(() => {
1124
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1125
+ return step?.type === "wait" ? step.state : undefined;
1126
+ })
1117
1127
  .toBe("waiting");
1118
1128
 
1119
1129
  await instance.pause();
@@ -1255,7 +1265,10 @@ describe("WorkflowRuntime", () => {
1255
1265
  await instance.create({ definitionVersion: "2026-03-19" });
1256
1266
 
1257
1267
  await expect
1258
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1268
+ .poll(() => {
1269
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1270
+ return step?.type === "wait" ? step.state : undefined;
1271
+ })
1259
1272
  .toBe("waiting");
1260
1273
 
1261
1274
  await instance.pause();
@@ -1295,7 +1308,10 @@ describe("WorkflowRuntime", () => {
1295
1308
 
1296
1309
  await instance.create({ definitionVersion: "2026-03-19" });
1297
1310
  await expect
1298
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1311
+ .poll(() => {
1312
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1313
+ return step?.type === "wait" ? step.state : undefined;
1314
+ })
1299
1315
  .toBe("waiting");
1300
1316
  await instance.pause();
1301
1317
 
@@ -1307,7 +1323,7 @@ describe("WorkflowRuntime", () => {
1307
1323
  expect(instance.getSteps_experimental().find((s) => s.id === "wait-1")).toMatchObject({
1308
1324
  type: "wait",
1309
1325
  state: "satisfied",
1310
- payload: JSON.stringify({ data: "test" })
1326
+ payload: { data: "test" }
1311
1327
  });
1312
1328
  });
1313
1329
  } finally {
@@ -1330,7 +1346,10 @@ describe("WorkflowRuntime", () => {
1330
1346
  await instance.create({ definitionVersion: "2026-03-19" });
1331
1347
  await expect.poll(() => instance.getStatus()).toBe("running");
1332
1348
  await expect
1333
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1349
+ .poll(() => {
1350
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1351
+ return step?.type === "wait" ? step.state : undefined;
1352
+ })
1334
1353
  .toBe("waiting");
1335
1354
 
1336
1355
  await instance.pause();
@@ -1348,6 +1367,235 @@ describe("WorkflowRuntime", () => {
1348
1367
  });
1349
1368
  });
1350
1369
 
1370
+ describe("handleInboundEvent()", () => {
1371
+ it("persists satisfied wait payload on inbound_events with claimed_by pointing at the wait step", async () => {
1372
+ const executeSpy = vi
1373
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1374
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1375
+ await this.wait("wait-inbound-row", "evt-claim", {
1376
+ timeoutAt: Date.now() + 86_400_000
1377
+ });
1378
+ });
1379
+
1380
+ try {
1381
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1382
+ await runInDurableObject(stub, async (instance, state) => {
1383
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1384
+ instance.onStatusChange_experimental = async (status) => {
1385
+ if (status === "running") return;
1386
+ resolve(status);
1387
+ };
1388
+
1389
+ await instance.create({ definitionVersion: "2026-03-19" });
1390
+
1391
+ await expect
1392
+ .poll(() => {
1393
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-inbound-row");
1394
+ return step?.type === "wait" ? step.state : undefined;
1395
+ })
1396
+ .toBe("waiting");
1397
+
1398
+ await instance.handleInboundEvent("evt-claim", { trace: "x" });
1399
+ await expect(promise).resolves.toBe("completed");
1400
+
1401
+ const formatted = instance.getSteps_experimental().find((s) => s.id === "wait-inbound-row");
1402
+ expect(formatted).toMatchObject({
1403
+ type: "wait",
1404
+ state: "satisfied",
1405
+ payload: { trace: "x" }
1406
+ });
1407
+
1408
+ const rows = state.storage.sql
1409
+ .exec<{ payload: string; claimed_by: string | null }>(
1410
+ `SELECT payload, claimed_by FROM inbound_events WHERE claimed_by = ?`,
1411
+ "wait-inbound-row"
1412
+ )
1413
+ .toArray();
1414
+ expect(rows).toHaveLength(1);
1415
+ expect(rows[0]!.claimed_by).toBe("wait-inbound-row");
1416
+ expect(JSON.parse(rows[0]!.payload)).toEqual({ trace: "x" });
1417
+ });
1418
+ } finally {
1419
+ executeSpy.mockRestore();
1420
+ }
1421
+ });
1422
+
1423
+ it("satisfies a waiting wait step when called without a payload", async () => {
1424
+ const executeSpy = vi
1425
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1426
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1427
+ const payload = await this.wait<undefined>("wait-no-payload", "evt");
1428
+ await this.run("after-wait-no-payload", async () => payload);
1429
+ });
1430
+
1431
+ try {
1432
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1433
+ await runInDurableObject(stub, async (instance) => {
1434
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1435
+ instance.onStatusChange_experimental = async (status) => {
1436
+ if (status === "running") return;
1437
+ resolve(status);
1438
+ };
1439
+
1440
+ await instance.create({ definitionVersion: "2026-03-19" });
1441
+
1442
+ await expect
1443
+ .poll(() => {
1444
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-no-payload");
1445
+ return step?.type === "wait" ? step.state : undefined;
1446
+ })
1447
+ .toBe("waiting");
1448
+
1449
+ await instance.handleInboundEvent("evt");
1450
+ await expect(promise).resolves.toBe("completed");
1451
+
1452
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-no-payload")).toMatchObject({
1453
+ type: "wait",
1454
+ state: "satisfied",
1455
+ payload: undefined
1456
+ });
1457
+
1458
+ const afterWait = instance.getSteps_experimental().find((s) => s.id === "after-wait-no-payload");
1459
+ expect(afterWait?.type).toBe("run");
1460
+ const attempts = (afterWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
1461
+ expect(attempts[attempts.length - 1]).toMatchObject({
1462
+ state: "succeeded",
1463
+ resultType: "none"
1464
+ });
1465
+ });
1466
+ } finally {
1467
+ executeSpy.mockRestore();
1468
+ }
1469
+ });
1470
+
1471
+ it("satisfies a waiting wait step when called with an explicit null payload", async () => {
1472
+ const executeSpy = vi
1473
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1474
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1475
+ const payload = await this.wait<null>("wait-null", "evt");
1476
+ await this.run("after-wait-null", async () => payload);
1477
+ });
1478
+
1479
+ try {
1480
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1481
+ await runInDurableObject(stub, async (instance) => {
1482
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1483
+ instance.onStatusChange_experimental = async (status) => {
1484
+ if (status === "running") return;
1485
+ resolve(status);
1486
+ };
1487
+
1488
+ await instance.create({ definitionVersion: "2026-03-19" });
1489
+
1490
+ await expect
1491
+ .poll(() => {
1492
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-null");
1493
+ return step?.type === "wait" ? step.state : undefined;
1494
+ })
1495
+ .toBe("waiting");
1496
+
1497
+ await instance.handleInboundEvent("evt", null);
1498
+ await expect(promise).resolves.toBe("completed");
1499
+
1500
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-null")).toMatchObject({
1501
+ type: "wait",
1502
+ state: "satisfied",
1503
+ payload: null
1504
+ });
1505
+
1506
+ const afterWait = instance.getSteps_experimental().find((s) => s.id === "after-wait-null");
1507
+ expect(afterWait?.type).toBe("run");
1508
+ const attempts = (afterWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
1509
+ expect(attempts[attempts.length - 1]).toMatchObject({
1510
+ state: "succeeded",
1511
+ resultType: "json",
1512
+ resultJson: "null"
1513
+ });
1514
+ });
1515
+ } finally {
1516
+ executeSpy.mockRestore();
1517
+ }
1518
+ });
1519
+
1520
+ it("satisfies a waiting wait step via queued payloadless event claimed on resume", async () => {
1521
+ const executeSpy = vi
1522
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1523
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1524
+ await this.wait("wait-queued", "evt");
1525
+ });
1526
+
1527
+ try {
1528
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1529
+ await runInDurableObject(stub, async (instance) => {
1530
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1531
+ instance.onStatusChange_experimental = async (status) => {
1532
+ if (status === "running" || status === "paused") return;
1533
+ resolve(status);
1534
+ };
1535
+
1536
+ await instance.create({ definitionVersion: "2026-03-19" });
1537
+
1538
+ await expect
1539
+ .poll(() => {
1540
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-queued");
1541
+ return step?.type === "wait" ? step.state : undefined;
1542
+ })
1543
+ .toBe("waiting");
1544
+
1545
+ await instance.pause();
1546
+
1547
+ // Queue the event without a payload while paused
1548
+ await instance.handleInboundEvent("evt");
1549
+
1550
+ // The wait step should still be waiting (event was only queued, not claimed)
1551
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-queued")).toMatchObject({
1552
+ type: "wait",
1553
+ state: "waiting"
1554
+ });
1555
+
1556
+ await instance.resume();
1557
+ await expect(promise).resolves.toBe("completed");
1558
+
1559
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-queued")).toMatchObject({
1560
+ type: "wait",
1561
+ state: "satisfied",
1562
+ payload: undefined
1563
+ });
1564
+ });
1565
+ } finally {
1566
+ executeSpy.mockRestore();
1567
+ }
1568
+ });
1569
+
1570
+ it("is a no-op when the workflow is in a terminal state", async () => {
1571
+ const executeSpy = vi
1572
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1573
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1574
+ await this.run("step-1", async () => "done");
1575
+ });
1576
+
1577
+ try {
1578
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1579
+ await runInDurableObject(stub, async (instance) => {
1580
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1581
+ instance.onStatusChange_experimental = async (status) => {
1582
+ if (status === "running") return;
1583
+ resolve(status);
1584
+ };
1585
+
1586
+ await instance.create({ definitionVersion: "2026-03-19" });
1587
+ await expect(promise).resolves.toBe("completed");
1588
+
1589
+ // Should not throw even though there is no matching wait step
1590
+ await instance.handleInboundEvent("any-event", { data: 1 });
1591
+ expect(instance.getStatus()).toBe("completed");
1592
+ });
1593
+ } finally {
1594
+ executeSpy.mockRestore();
1595
+ }
1596
+ });
1597
+ });
1598
+
1351
1599
  describe("getWorkflowEvents_experimental()", () => {
1352
1600
  it("records 'created' when workflow is first initialized", async () => {
1353
1601
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
@@ -1513,20 +1761,22 @@ describe("WorkflowRuntime", () => {
1513
1761
  });
1514
1762
  });
1515
1763
 
1764
+
1516
1765
  describe("WorkflowRuntimeContext", () => {
1517
1766
  describe("run steps", () => {
1518
- describe("getOrCreateStep()", () => {
1767
+ describe("getOrCreateRunStep()", () => {
1519
1768
  it("creates a new run step", async () => {
1520
1769
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1521
1770
  await runInDurableObject(stub, async (_instance, state) => {
1522
1771
  const context = new WorkflowRuntimeContext(state.storage);
1523
- const step = await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1772
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1524
1773
  expect(step).toMatchObject({
1525
1774
  id: "step-1",
1526
1775
  type: "run",
1527
- state: "pending",
1528
- attemptCount: 0
1776
+ maxAttempts: 3,
1777
+ parentStepId: null
1529
1778
  });
1779
+ expect(step.attempts).toEqual([]);
1530
1780
  });
1531
1781
  });
1532
1782
 
@@ -1534,25 +1784,19 @@ describe("WorkflowRuntime", () => {
1534
1784
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1535
1785
  await runInDurableObject(stub, async (_instance, state) => {
1536
1786
  const context = new WorkflowRuntimeContext(state.storage);
1537
- const first = await context.getOrCreateStep(createRunStepId("step-1"), {
1538
- type: "run",
1539
- parentStepId: null
1540
- });
1541
- const second = await context.getOrCreateStep(createRunStepId("step-1"), {
1542
- type: "run",
1543
- parentStepId: null
1544
- });
1787
+ const first = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1788
+ const second = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1545
1789
  expect(first).toEqual(second);
1546
1790
  });
1547
1791
  });
1548
1792
 
1549
- it("does not write an 'attempt_started' step event when a run step is already in progress", async () => {
1793
+ it("leaves attempts empty until handleRunAttemptStarted", async () => {
1550
1794
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1551
- await runInDurableObject(stub, async (instance, state) => {
1795
+ await runInDurableObject(stub, async (_instance, state) => {
1552
1796
  const context = new WorkflowRuntimeContext(state.storage);
1553
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1554
-
1555
- await expect(instance.getStepEvents_experimental()).toMatchObject([]);
1797
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1798
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1799
+ expect(step.attempts).toEqual([]);
1556
1800
  });
1557
1801
  });
1558
1802
 
@@ -1560,8 +1804,7 @@ describe("WorkflowRuntime", () => {
1560
1804
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1561
1805
  await runInDurableObject(stub, async (_instance, state) => {
1562
1806
  const context = new WorkflowRuntimeContext(state.storage);
1563
- const step = await context.getOrCreateStep(createRunStepId("step-1"), {
1564
- type: "run",
1807
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), {
1565
1808
  maxAttempts: 5,
1566
1809
  parentStepId: null
1567
1810
  });
@@ -1574,14 +1817,14 @@ describe("WorkflowRuntime", () => {
1574
1817
  });
1575
1818
  });
1576
1819
 
1577
- describe("hasRunningOrWaitingChildSteps()", () => {
1820
+ describe("hasInProgressChildSteps()", () => {
1578
1821
  it("returns false when the run step has no direct child rows", async () => {
1579
1822
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1580
1823
  await runInDurableObject(stub, async (_instance, state) => {
1581
1824
  const context = new WorkflowRuntimeContext(state.storage);
1582
- await context.getOrCreateStep(createRunStepId("leaf"), { type: "run", parentStepId: null });
1583
- await context.handleRunAttemptEvent(createRunStepId("leaf"), { type: "running", attemptCount: 1 });
1584
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("leaf"))).resolves.toBe(false);
1825
+ context.getOrCreateRunStep(createRunStepId("leaf"), { parentStepId: null });
1826
+ context.handleRunAttemptStarted(createRunStepId("leaf"));
1827
+ expect(context.hasInProgressChildSteps(createRunStepId("leaf"))).toBe(false);
1585
1828
  });
1586
1829
  });
1587
1830
 
@@ -1589,13 +1832,12 @@ describe("WorkflowRuntime", () => {
1589
1832
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1590
1833
  await runInDurableObject(stub, async (_instance, state) => {
1591
1834
  const context = new WorkflowRuntimeContext(state.storage);
1592
- await context.getOrCreateStep(createRunStepId("parent"), { type: "run", parentStepId: null });
1593
- await context.handleRunAttemptEvent(createRunStepId("parent"), { type: "running", attemptCount: 1 });
1594
- await context.getOrCreateStep(createRunStepId("child"), {
1595
- type: "run",
1835
+ context.getOrCreateRunStep(createRunStepId("parent"), { parentStepId: null });
1836
+ context.handleRunAttemptStarted(createRunStepId("parent"));
1837
+ context.getOrCreateRunStep(createRunStepId("child"), {
1596
1838
  parentStepId: createRunStepId("parent")
1597
1839
  });
1598
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1840
+ expect(context.hasInProgressChildSteps(createRunStepId("parent"))).toBe(true);
1599
1841
  });
1600
1842
  });
1601
1843
 
@@ -1603,14 +1845,13 @@ describe("WorkflowRuntime", () => {
1603
1845
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1604
1846
  await runInDurableObject(stub, async (_instance, state) => {
1605
1847
  const context = new WorkflowRuntimeContext(state.storage);
1606
- await context.getOrCreateStep(createRunStepId("parent"), { type: "run", parentStepId: null });
1607
- await context.handleRunAttemptEvent(createRunStepId("parent"), { type: "running", attemptCount: 1 });
1608
- await context.getOrCreateStep(createRunStepId("child"), {
1609
- type: "run",
1848
+ context.getOrCreateRunStep(createRunStepId("parent"), { parentStepId: null });
1849
+ context.handleRunAttemptStarted(createRunStepId("parent"));
1850
+ context.getOrCreateRunStep(createRunStepId("child"), {
1610
1851
  parentStepId: createRunStepId("parent")
1611
1852
  });
1612
- await context.handleRunAttemptEvent(createRunStepId("child"), { type: "running", attemptCount: 1 });
1613
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1853
+ context.handleRunAttemptStarted(createRunStepId("child"));
1854
+ expect(context.hasInProgressChildSteps(createRunStepId("parent"))).toBe(true);
1614
1855
  });
1615
1856
  });
1616
1857
 
@@ -1618,14 +1859,13 @@ describe("WorkflowRuntime", () => {
1618
1859
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1619
1860
  await runInDurableObject(stub, async (_instance, state) => {
1620
1861
  const context = new WorkflowRuntimeContext(state.storage);
1621
- await context.getOrCreateStep(createRunStepId("gp"), { type: "run", parentStepId: null });
1622
- await context.handleRunAttemptEvent(createRunStepId("gp"), { type: "running", attemptCount: 1 });
1623
- await context.getOrCreateStep(createRunStepId("mid"), { type: "run", parentStepId: createRunStepId("gp") });
1624
- await context.getOrCreateStep(createRunStepId("leaf"), {
1625
- type: "run",
1862
+ context.getOrCreateRunStep(createRunStepId("gp"), { parentStepId: null });
1863
+ context.handleRunAttemptStarted(createRunStepId("gp"));
1864
+ context.getOrCreateRunStep(createRunStepId("mid"), { parentStepId: createRunStepId("gp") });
1865
+ context.getOrCreateRunStep(createRunStepId("leaf"), {
1626
1866
  parentStepId: createRunStepId("mid")
1627
1867
  });
1628
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("gp"))).resolves.toBe(true);
1868
+ expect(context.hasInProgressChildSteps(createRunStepId("gp"))).toBe(true);
1629
1869
  });
1630
1870
  });
1631
1871
 
@@ -1633,14 +1873,13 @@ describe("WorkflowRuntime", () => {
1633
1873
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1634
1874
  await runInDurableObject(stub, async (_instance, state) => {
1635
1875
  const context = new WorkflowRuntimeContext(state.storage);
1636
- await context.getOrCreateStep(createRunStepId("mid"), { type: "run", parentStepId: null });
1637
- await context.handleRunAttemptEvent(createRunStepId("mid"), { type: "running", attemptCount: 1 });
1638
- await context.getOrCreateStep(createRunStepId("leaf"), {
1639
- type: "run",
1876
+ context.getOrCreateRunStep(createRunStepId("mid"), { parentStepId: null });
1877
+ context.handleRunAttemptStarted(createRunStepId("mid"));
1878
+ context.getOrCreateRunStep(createRunStepId("leaf"), {
1640
1879
  parentStepId: createRunStepId("mid")
1641
1880
  });
1642
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("mid"))).resolves.toBe(true);
1643
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("leaf"))).resolves.toBe(false);
1881
+ expect(context.hasInProgressChildSteps(createRunStepId("mid"))).toBe(true);
1882
+ expect(context.hasInProgressChildSteps(createRunStepId("leaf"))).toBe(false);
1644
1883
  });
1645
1884
  });
1646
1885
 
@@ -1648,20 +1887,15 @@ describe("WorkflowRuntime", () => {
1648
1887
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1649
1888
  await runInDurableObject(stub, async (_instance, state) => {
1650
1889
  const context = new WorkflowRuntimeContext(state.storage);
1651
- await context.getOrCreateStep(createRunStepId("par"), { type: "run", parentStepId: null });
1652
- await context.handleRunAttemptEvent(createRunStepId("par"), { type: "running", attemptCount: 1 });
1653
- await context.getOrCreateStep(createRunStepId("bad-child"), {
1654
- type: "run",
1655
- parentStepId: createRunStepId("par")
1890
+ context.getOrCreateRunStep(createRunStepId("par"), { parentStepId: null });
1891
+ context.handleRunAttemptStarted(createRunStepId("par"));
1892
+ context.getOrCreateRunStep(createRunStepId("bad-child"), {
1893
+ parentStepId: createRunStepId("par"),
1894
+ maxAttempts: 1
1656
1895
  });
1657
- await context.handleRunAttemptEvent(createRunStepId("bad-child"), { type: "running", attemptCount: 1 });
1658
- await context.handleRunAttemptEvent(createRunStepId("bad-child"), {
1659
- type: "failed",
1660
- errorMessage: "x",
1661
- attemptCount: 1,
1662
- isNonRetryableStepError: true
1663
- });
1664
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("par"))).resolves.toBe(false);
1896
+ context.handleRunAttemptStarted(createRunStepId("bad-child"));
1897
+ context.handleRunAttemptFailed(createRunStepId("bad-child"), { errorMessage: "x" });
1898
+ expect(context.hasInProgressChildSteps(createRunStepId("par"))).toBe(false);
1665
1899
  });
1666
1900
  });
1667
1901
 
@@ -1669,453 +1903,154 @@ describe("WorkflowRuntime", () => {
1669
1903
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1670
1904
  await runInDurableObject(stub, async (_instance, state) => {
1671
1905
  const context = new WorkflowRuntimeContext(state.storage);
1672
- await context.getOrCreateStep(createRunStepId("run-parent"), { type: "run", parentStepId: null });
1673
- await context.handleRunAttemptEvent(createRunStepId("run-parent"), { type: "running", attemptCount: 1 });
1674
- const wakeAt = new Date(Date.now() + 60_000);
1675
- await context.getOrCreateStep(createSleepStepId("child-sleep"), {
1676
- type: "sleep",
1677
- wakeAt,
1906
+ context.getOrCreateRunStep(createRunStepId("run-parent"), { parentStepId: null });
1907
+ context.handleRunAttemptStarted(createRunStepId("run-parent"));
1908
+ context.getOrCreateSleepStep(createSleepStepId("child-sleep"), {
1909
+ wakeAt: new Date(Date.now() + 60_000),
1678
1910
  parentStepId: createRunStepId("run-parent")
1679
1911
  });
1680
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("run-parent"))).resolves.toBe(true);
1912
+ expect(context.hasInProgressChildSteps(createRunStepId("run-parent"))).toBe(true);
1681
1913
  });
1682
1914
  });
1683
1915
  });
1684
1916
 
1685
- describe("handleRunAttemptEvent({ type: 'running' })", () => {
1686
- it("moves a run step from 'pending' to 'running'", async () => {
1917
+ describe("handleRunAttemptStarted()", () => {
1918
+ it("inserts a started attempt", async () => {
1687
1919
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1688
1920
  await runInDurableObject(stub, async (_instance, state) => {
1689
1921
  const context = new WorkflowRuntimeContext(state.storage);
1690
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1691
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1692
- type: "running",
1693
- attemptCount: 1
1694
- });
1695
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1696
- type: "run",
1697
- parentStepId: null
1698
- });
1699
- expect(updatedStep).toMatchObject({
1700
- state: "running",
1701
- attemptCount: 1
1702
- });
1922
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1923
+ const started = context.handleRunAttemptStarted(createRunStepId("step-1"));
1924
+ expect(started).toMatchObject({ state: "started", stepId: "step-1" });
1925
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1926
+ expect(step.attempts).toHaveLength(1);
1927
+ expect(step.attempts[0]).toMatchObject({ state: "started" });
1703
1928
  });
1704
1929
  });
1705
1930
 
1706
- it("writes an 'attempt_started' step event when a run step is started", async () => {
1931
+ it("throws when the step does not exist", async () => {
1707
1932
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1708
- await runInDurableObject(stub, async (instance, state) => {
1933
+ await runInDurableObject(stub, async (_instance, state) => {
1709
1934
  const context = new WorkflowRuntimeContext(state.storage);
1710
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1711
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1712
- type: "running",
1713
- attemptCount: 1
1714
- });
1715
- expect(await instance.getStepEvents_experimental()).toMatchObject([
1716
- {
1717
- type: "attempt_started",
1718
- stepId: "step-1",
1719
- attemptNumber: 1,
1720
- recordedAt: expect.any(Date)
1721
- }
1722
- ]);
1935
+ expect(() => context.handleRunAttemptStarted(createRunStepId("nonexistent"))).toThrow(/not found/);
1723
1936
  });
1724
1937
  });
1725
1938
 
1726
- it("throws when the step does not exist", async () => {
1939
+ it("throws when an attempt is already in progress", async () => {
1727
1940
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1728
1941
  await runInDurableObject(stub, async (_instance, state) => {
1729
1942
  const context = new WorkflowRuntimeContext(state.storage);
1730
- await expect(
1731
- context.handleRunAttemptEvent(createRunStepId("nonexistent"), {
1732
- type: "running",
1733
- attemptCount: 1
1734
- })
1735
- ).rejects.toThrow(/not found/);
1943
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1944
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
1945
+ expect(() => context.handleRunAttemptStarted(createRunStepId("step-1"))).toThrow(/already in progress/);
1736
1946
  });
1737
1947
  });
1948
+ });
1738
1949
 
1739
- it("throws when the step is not in 'pending' state", async () => {
1950
+ describe("handleRunAttemptSucceeded()", () => {
1951
+ it("marks the in-flight attempt succeeded with a json result", async () => {
1740
1952
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1741
1953
  await runInDurableObject(stub, async (_instance, state) => {
1742
1954
  const context = new WorkflowRuntimeContext(state.storage);
1743
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1744
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1745
- type: "running",
1746
- attemptCount: 1
1955
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1956
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
1957
+ const done = context.handleRunAttemptSucceeded(createRunStepId("step-1"), JSON.stringify(0));
1958
+ expect(done).toMatchObject({
1959
+ state: "succeeded",
1960
+ resultType: "json",
1961
+ resultJson: JSON.stringify(0)
1962
+ });
1963
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1964
+ expect(step.attempts).toHaveLength(1);
1965
+ expect(step.attempts[0]).toMatchObject({
1966
+ state: "succeeded",
1967
+ resultType: "json",
1968
+ resultJson: JSON.stringify(0)
1747
1969
  });
1748
- await expect(
1749
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1750
- type: "running",
1751
- attemptCount: 2
1752
- })
1753
- ).rejects.toThrow(/Expected 'pending' but got running/);
1754
1970
  });
1755
1971
  });
1756
1972
 
1757
- it("rejects when 'next_attempt_at' is in the future", async () => {
1973
+ it("marks the in-flight attempt succeeded with result_type none", async () => {
1758
1974
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1759
1975
  await runInDurableObject(stub, async (_instance, state) => {
1760
1976
  const context = new WorkflowRuntimeContext(state.storage);
1761
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1762
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1763
- type: "running",
1764
- attemptCount: 1
1765
- });
1766
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1767
- type: "failed",
1768
- attemptCount: 1,
1769
- errorMessage: "backoff"
1977
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1978
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
1979
+ const done = context.handleRunAttemptSucceeded(createRunStepId("step-1"), null);
1980
+ expect(done).toMatchObject({
1981
+ state: "succeeded",
1982
+ resultType: "none"
1770
1983
  });
1771
- const future = Date.now() + 3600_000;
1772
- state.storage.sql.exec("UPDATE steps SET next_attempt_at = ? WHERE id = 'step-1'", future);
1773
- await expect(
1774
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1775
- type: "running",
1776
- attemptCount: 2
1777
- })
1778
- ).rejects.toThrow(/next attempt at/);
1779
1984
  });
1780
1985
  });
1781
1986
 
1782
- it("rejects when 'attemptCount' does not match the expected next attempt", async () => {
1987
+ it("throws when the step does not exist", async () => {
1783
1988
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1784
1989
  await runInDurableObject(stub, async (_instance, state) => {
1785
1990
  const context = new WorkflowRuntimeContext(state.storage);
1786
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1787
- await expect(
1788
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1789
- type: "running",
1790
- attemptCount: 99
1791
- })
1792
- ).rejects.toThrow(/Expected 98 but got 0/);
1991
+ expect(() => context.handleRunAttemptSucceeded(createRunStepId("nonexistent"), null)).toThrow(/not found/);
1992
+ });
1993
+ });
1994
+
1995
+ it("throws when no attempt is in progress", async () => {
1996
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1997
+ await runInDurableObject(stub, async (_instance, state) => {
1998
+ const context = new WorkflowRuntimeContext(state.storage);
1999
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2000
+ expect(() => context.handleRunAttemptSucceeded(createRunStepId("step-1"), null)).toThrow(
2001
+ /No attempt in progress/
2002
+ );
1793
2003
  });
1794
2004
  });
1795
2005
  });
1796
2006
 
1797
- describe("handleRunAttemptEvent({ type: 'succeeded' })", () => {
1798
- it("moves a run step from 'running' to 'succeeded'", async () => {
2007
+ describe("handleRunAttemptFailed()", () => {
2008
+ it("marks terminal failed when max attempts exhausted", async () => {
1799
2009
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1800
2010
  await runInDurableObject(stub, async (_instance, state) => {
1801
2011
  const context = new WorkflowRuntimeContext(state.storage);
1802
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1803
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1804
- type: "running",
1805
- attemptCount: 1
1806
- });
1807
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1808
- type: "succeeded",
1809
- attemptCount: 1,
1810
- result: JSON.stringify({ value: 0 })
1811
- });
1812
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1813
- type: "run",
1814
- parentStepId: null
1815
- });
1816
- expect(updatedStep).toMatchObject({
1817
- state: "succeeded",
1818
- attemptCount: 1,
1819
- result: JSON.stringify({ value: 0 }),
1820
- resolvedAt: expect.any(Date)
1821
- });
1822
- });
1823
- });
1824
-
1825
- it("writes an 'attempt_succeeded' step event when a run step is succeeded", async () => {
1826
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1827
- await runInDurableObject(stub, async (instance, state) => {
1828
- const context = new WorkflowRuntimeContext(state.storage);
1829
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1830
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1831
- type: "running",
1832
- attemptCount: 1
1833
- });
1834
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1835
- type: "succeeded",
1836
- attemptCount: 1,
1837
- result: JSON.stringify({ value: 0 })
1838
- });
1839
- expect(await instance.getStepEvents_experimental()).toMatchObject([
1840
- {
1841
- type: "attempt_started",
1842
- stepId: "step-1",
1843
- attemptNumber: 1,
1844
- recordedAt: expect.any(Date)
1845
- },
1846
- {
1847
- type: "attempt_succeeded",
1848
- stepId: "step-1",
1849
- attemptNumber: 1,
1850
- recordedAt: expect.any(Date)
1851
- }
1852
- ]);
1853
- });
1854
- });
1855
-
1856
- it("throws when the attempt number does not match", async () => {
1857
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1858
- await runInDurableObject(stub, async (_instance, state) => {
1859
- const context = new WorkflowRuntimeContext(state.storage);
1860
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1861
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1862
- type: "running",
1863
- attemptCount: 1
1864
- });
1865
- await expect(
1866
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1867
- type: "succeeded",
1868
- attemptCount: 999,
1869
- result: JSON.stringify({ value: 0 })
1870
- })
1871
- ).rejects.toThrow(/Unexpected attempt count/);
1872
- });
1873
- });
1874
-
1875
- it("throws when the step does not exist", async () => {
1876
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1877
- await runInDurableObject(stub, async (_instance, state) => {
1878
- const context = new WorkflowRuntimeContext(state.storage);
1879
- await expect(
1880
- context.handleRunAttemptEvent(createRunStepId("nonexistent"), {
1881
- type: "succeeded",
1882
- attemptCount: 1,
1883
- result: JSON.stringify({ value: 0 })
1884
- })
1885
- ).rejects.toThrow(/not found/);
1886
- });
1887
- });
1888
-
1889
- it("rejects when the step is still in 'pending' state", async () => {
1890
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1891
- await runInDurableObject(stub, async (_instance, state) => {
1892
- const context = new WorkflowRuntimeContext(state.storage);
1893
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1894
- await expect(
1895
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1896
- type: "succeeded",
1897
- attemptCount: 1,
1898
- result: JSON.stringify({ value: 1 })
1899
- })
1900
- ).rejects.toThrow(/Expected 'running' but got pending/);
1901
- });
1902
- });
1903
- });
1904
-
1905
- describe("handleRunAttemptEvent({ type: 'failed' })", () => {
1906
- it("moves a run step from 'running' to 'failed'", async () => {
1907
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1908
- await runInDurableObject(stub, async (_instance, state) => {
1909
- const context = new WorkflowRuntimeContext(state.storage);
1910
- await context.getOrCreateStep(createRunStepId("step-1"), {
1911
- type: "run",
1912
- maxAttempts: 1,
1913
- parentStepId: null
1914
- });
1915
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1916
- type: "running",
1917
- attemptCount: 1
1918
- });
1919
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1920
- type: "failed",
1921
- attemptCount: 1,
1922
- errorMessage: "error"
1923
- });
1924
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1925
- type: "run",
1926
- maxAttempts: 1,
1927
- parentStepId: null
1928
- });
1929
- expect(updatedStep).toMatchObject({
1930
- state: "failed",
1931
- attemptCount: 1,
1932
- errorMessage: "error"
2012
+ context.getOrCreateRunStep(createRunStepId("step-1"), { maxAttempts: 1, parentStepId: null });
2013
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2014
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "error" });
2015
+ expect(failed).toMatchObject({
2016
+ state: "failed",
2017
+ errorMessage: "error",
2018
+ nextAttemptAt: undefined
1933
2019
  });
2020
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2021
+ expect(step.attempts).toHaveLength(1);
2022
+ expect(step.attempts[0]).toMatchObject({ state: "failed", errorMessage: "error" });
1934
2023
  });
1935
2024
  });
1936
2025
 
1937
- it("writes an 'attempt_failed' step event when a run step is failed", async () => {
1938
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1939
- await runInDurableObject(stub, async (instance, state) => {
1940
- const context = new WorkflowRuntimeContext(state.storage);
1941
- await context.getOrCreateStep(createRunStepId("step-1"), {
1942
- type: "run",
1943
- maxAttempts: 1,
1944
- parentStepId: null
1945
- });
1946
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1947
- type: "running",
1948
- attemptCount: 1
1949
- });
1950
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1951
- type: "failed",
1952
- attemptCount: 1,
1953
- errorMessage: "error"
1954
- });
1955
- expect(await instance.getStepEvents_experimental()).toMatchObject([
1956
- {
1957
- type: "attempt_started",
1958
- stepId: "step-1",
1959
- attemptNumber: 1,
1960
- recordedAt: expect.any(Date)
1961
- },
1962
- {
1963
- type: "attempt_failed",
1964
- stepId: "step-1",
1965
- attemptNumber: 1,
1966
- errorMessage: "error",
1967
- recordedAt: expect.any(Date)
1968
- }
1969
- ]);
1970
- });
1971
- });
1972
-
1973
- it("moves a run step back to 'pending' when retries are available", async () => {
2026
+ it("records next_attempt_at when retries remain", async () => {
1974
2027
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1975
2028
  await runInDurableObject(stub, async (_instance, state) => {
1976
2029
  const context = new WorkflowRuntimeContext(state.storage);
1977
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1978
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1979
- type: "running",
1980
- attemptCount: 1
1981
- });
1982
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1983
- type: "failed",
1984
- attemptCount: 1,
1985
- errorMessage: "transient error"
1986
- });
1987
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1988
- type: "run",
1989
- parentStepId: null
1990
- });
1991
- expect(updatedStep).toMatchObject({
1992
- state: "pending",
1993
- attemptCount: 1
1994
- });
1995
- expect(updatedStep).toHaveProperty("nextAttemptAt");
1996
- expect((updatedStep as { nextAttemptAt: Date }).nextAttemptAt.getTime()).toBeGreaterThan(Date.now());
2030
+ const before = Date.now();
2031
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2032
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2033
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "transient" });
2034
+ const after = Date.now();
2035
+ expect(failed.state).toBe("failed");
2036
+ if (failed.state !== "failed") throw new Error("expected failed");
2037
+ expect(failed.nextAttemptAt).toBeDefined();
2038
+ expect(failed.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(before + 250);
2039
+ expect(failed.nextAttemptAt!.getTime()).toBeLessThanOrEqual(after + 500 + 100);
1997
2040
  });
1998
2041
  });
1999
2042
 
2000
- it("moves a run step to 'failed' when 'isNonRetryableStepError' is true and retries are available", async () => {
2043
+ it("marks terminal failed when isNonRetryableStepError is true", async () => {
2001
2044
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2002
2045
  await runInDurableObject(stub, async (_instance, state) => {
2003
2046
  const context = new WorkflowRuntimeContext(state.storage);
2004
- await context.getOrCreateStep(createRunStepId("step-1"), {
2005
- type: "run",
2006
- maxAttempts: 10,
2007
- parentStepId: null
2008
- });
2009
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2010
- type: "running",
2011
- attemptCount: 1
2012
- });
2013
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2014
- type: "failed",
2015
- attemptCount: 1,
2016
- errorMessage: "transient error",
2047
+ context.getOrCreateRunStep(createRunStepId("step-1"), { maxAttempts: 10, parentStepId: null });
2048
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2049
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), {
2050
+ errorMessage: "x",
2017
2051
  isNonRetryableStepError: true
2018
2052
  });
2019
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
2020
- type: "run",
2021
- parentStepId: null
2022
- });
2023
- expect(updatedStep).toMatchObject({
2024
- state: "failed",
2025
- attemptCount: 1,
2026
- errorMessage: "transient error"
2027
- });
2028
- });
2029
- });
2030
-
2031
- it("schedules an alarm when retries are available", async () => {
2032
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2033
- await runInDurableObject(stub, async (_instance, state) => {
2034
- const context = new WorkflowRuntimeContext(state.storage);
2035
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2036
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2037
- type: "running",
2038
- attemptCount: 1
2039
- });
2040
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2041
- type: "failed",
2042
- attemptCount: 1,
2043
- errorMessage: "transient error"
2044
- });
2045
- const alarm = await state.storage.getAlarm();
2046
- expect(alarm).not.toBeNull();
2047
- expect(alarm).toBeGreaterThan(Date.now());
2048
- });
2049
- });
2050
-
2051
- it("replaces an existing alarm when retries are available", async () => {
2052
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2053
- await runInDurableObject(stub, async (_instance, state) => {
2054
- await state.storage.setAlarm(Date.now() + 999_999);
2055
-
2056
- const context = new WorkflowRuntimeContext(state.storage);
2057
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2058
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2059
- type: "running",
2060
- attemptCount: 1
2061
- });
2062
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2063
- type: "failed",
2064
- attemptCount: 1,
2065
- errorMessage: "transient error"
2066
- });
2067
- const alarm = await state.storage.getAlarm();
2068
- expect(alarm).not.toBeNull();
2069
- expect(alarm).toBeLessThan(Date.now() + 999_999);
2070
- });
2071
- });
2072
-
2073
- it("writes an 'attempt_failed' step event when retrying", async () => {
2074
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2075
- await runInDurableObject(stub, async (instance, state) => {
2076
- const context = new WorkflowRuntimeContext(state.storage);
2077
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2078
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2079
- type: "running",
2080
- attemptCount: 1
2081
- });
2082
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2083
- type: "failed",
2084
- attemptCount: 1,
2085
- errorMessage: "transient error"
2086
- });
2087
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2088
- {
2089
- type: "attempt_started",
2090
- stepId: "step-1",
2091
- attemptNumber: 1
2092
- },
2093
- {
2094
- type: "attempt_failed",
2095
- stepId: "step-1",
2096
- attemptNumber: 1,
2097
- errorMessage: "transient error"
2098
- }
2099
- ]);
2100
- });
2101
- });
2102
-
2103
- it("throws when the attempt number does not match", async () => {
2104
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2105
- await runInDurableObject(stub, async (_instance, state) => {
2106
- const context = new WorkflowRuntimeContext(state.storage);
2107
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2108
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2109
- type: "running",
2110
- attemptCount: 1
2111
- });
2112
- await expect(
2113
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
2114
- type: "failed",
2115
- attemptCount: 999,
2116
- errorMessage: "error"
2117
- })
2118
- ).rejects.toThrow(/Unexpected attempt count/);
2053
+ expect(failed).toMatchObject({ state: "failed", nextAttemptAt: undefined });
2119
2054
  });
2120
2055
  });
2121
2056
 
@@ -2123,80 +2058,41 @@ describe("WorkflowRuntime", () => {
2123
2058
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2124
2059
  await runInDurableObject(stub, async (_instance, state) => {
2125
2060
  const context = new WorkflowRuntimeContext(state.storage);
2126
- await expect(
2127
- context.handleRunAttemptEvent(createRunStepId("nonexistent"), {
2128
- type: "failed",
2129
- attemptCount: 1,
2130
- errorMessage: "error"
2131
- })
2132
- ).rejects.toThrow(/not found/);
2133
- });
2134
- });
2135
-
2136
- it("uses backoff delay for next attempt when retrying", async () => {
2137
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2138
- await runInDurableObject(stub, async (_instance, state) => {
2139
- const context = new WorkflowRuntimeContext(state.storage);
2140
- const before = Date.now();
2141
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2142
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2143
- type: "running",
2144
- attemptCount: 1
2145
- });
2146
- await context.handleRunAttemptEvent(createRunStepId("step-1"), {
2147
- type: "failed",
2148
- attemptCount: 1,
2149
- errorMessage: "transient error"
2150
- });
2151
- const after = Date.now();
2152
- const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
2153
- type: "run",
2154
- parentStepId: null
2155
- });
2156
- expect(updatedStep).toMatchObject({
2157
- state: "pending",
2158
- attemptCount: 1
2159
- });
2160
- const nextAttemptAt = (updatedStep as { nextAttemptAt: Date }).nextAttemptAt.getTime();
2161
- expect(nextAttemptAt).toBeGreaterThanOrEqual(before + 250);
2162
- expect(nextAttemptAt).toBeLessThanOrEqual(after + 500 + 100);
2061
+ expect(() => context.handleRunAttemptFailed(createRunStepId("nonexistent"), { errorMessage: "e" })).toThrow(
2062
+ /not found/
2063
+ );
2163
2064
  });
2164
2065
  });
2165
2066
 
2166
- it("rejects when the step is still in 'pending' state", async () => {
2067
+ it("throws when no attempt is in progress", async () => {
2167
2068
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2168
2069
  await runInDurableObject(stub, async (_instance, state) => {
2169
2070
  const context = new WorkflowRuntimeContext(state.storage);
2170
- await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
2171
- await expect(
2172
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
2173
- type: "failed",
2174
- attemptCount: 1,
2175
- errorMessage: "bad"
2176
- })
2177
- ).rejects.toThrow(/Expected 'running' but got pending/);
2071
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2072
+ expect(() => context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "bad" })).toThrow(
2073
+ /No attempt in progress/
2074
+ );
2178
2075
  });
2179
2076
  });
2180
2077
  });
2181
2078
  });
2182
2079
 
2183
2080
  describe("sleep steps", () => {
2184
- describe("getOrCreateStep()", () => {
2081
+ describe("getOrCreateSleepStep()", () => {
2185
2082
  it("creates a sleep step", async () => {
2186
2083
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2187
2084
  await runInDurableObject(stub, async (_instance, state) => {
2188
2085
  const context = new WorkflowRuntimeContext(state.storage);
2189
2086
  const wakeAt = new Date(Date.now() + 60_000);
2190
- const step = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2191
- type: "sleep",
2192
- wakeAt: wakeAt,
2087
+ const step = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2088
+ wakeAt,
2193
2089
  parentStepId: null
2194
2090
  });
2195
2091
  expect(step).toMatchObject({
2196
2092
  id: "sleep-1",
2197
2093
  type: "sleep",
2198
2094
  state: "waiting",
2199
- wakeAt: wakeAt
2095
+ wakeAt
2200
2096
  });
2201
2097
  });
2202
2098
  });
@@ -2205,13 +2101,9 @@ describe("WorkflowRuntime", () => {
2205
2101
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2206
2102
  await runInDurableObject(stub, async (_instance, state) => {
2207
2103
  const context = new WorkflowRuntimeContext(state.storage);
2208
- const first = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2209
- type: "sleep",
2210
- wakeAt: new Date(),
2211
- parentStepId: null
2212
- });
2213
- const second = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2214
- type: "sleep",
2104
+ const w = new Date();
2105
+ const first = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), { wakeAt: w, parentStepId: null });
2106
+ const second = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2215
2107
  wakeAt: new Date(),
2216
2108
  parentStepId: null
2217
2109
  });
@@ -2219,178 +2111,75 @@ describe("WorkflowRuntime", () => {
2219
2111
  });
2220
2112
  });
2221
2113
 
2222
- it("writes a 'sleep_waiting' step event", async () => {
2223
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2224
- await runInDurableObject(stub, async (instance, state) => {
2225
- const context = new WorkflowRuntimeContext(state.storage);
2226
- const wakeAt = new Date();
2227
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2228
- type: "sleep",
2229
- wakeAt: wakeAt,
2230
- parentStepId: null
2231
- });
2232
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2233
- {
2234
- type: "sleep_waiting",
2235
- stepId: "sleep-1",
2236
- wakeAt: wakeAt,
2237
- recordedAt: expect.any(Date)
2238
- }
2239
- ]);
2240
- });
2241
- });
2242
-
2243
- it("schedules an alarm", async () => {
2114
+ it("does not set a durable object alarm by itself", async () => {
2244
2115
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2245
2116
  await runInDurableObject(stub, async (_instance, state) => {
2246
2117
  const context = new WorkflowRuntimeContext(state.storage);
2247
2118
  const wakeAt = new Date(Date.now() + 60_000);
2248
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2249
- type: "sleep",
2250
- wakeAt: wakeAt,
2251
- parentStepId: null
2252
- });
2253
- expect(await state.storage.getAlarm()).toBe(wakeAt.getTime());
2119
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), { wakeAt, parentStepId: null });
2120
+ expect(await state.storage.getAlarm()).toBeNull();
2254
2121
  });
2255
2122
  });
2256
2123
 
2257
- it("replaces any existing alarm with a new alarm", async () => {
2124
+ it("leaves an existing alarm unchanged when creating a sleep step", async () => {
2258
2125
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2259
2126
  await runInDurableObject(stub, async (_instance, state) => {
2260
- await state.storage.setAlarm(Date.now() + 999_999);
2261
-
2127
+ const prior = Date.now() + 999_999;
2128
+ await state.storage.setAlarm(prior);
2262
2129
  const context = new WorkflowRuntimeContext(state.storage);
2263
- const wakeAt = new Date(Date.now() + 60_000);
2264
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2265
- type: "sleep",
2266
- wakeAt: wakeAt,
2130
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2131
+ wakeAt: new Date(Date.now() + 60_000),
2267
2132
  parentStepId: null
2268
2133
  });
2269
- expect(await state.storage.getAlarm()).toBe(wakeAt.getTime());
2134
+ expect(await state.storage.getAlarm()).toBe(prior);
2270
2135
  });
2271
2136
  });
2272
2137
 
2273
- it("does not schedule an alarm when an existing sleep step has a past wake_at", async () => {
2138
+ it("does not set an alarm when re-reading an existing sleep step after deleteAlarm", async () => {
2274
2139
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2275
2140
  await runInDurableObject(stub, async (_instance, state) => {
2276
2141
  const context = new WorkflowRuntimeContext(state.storage);
2277
2142
  const pastWakeAt = new Date(Date.now() - 10_000);
2278
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2279
- type: "sleep",
2143
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2280
2144
  wakeAt: pastWakeAt,
2281
2145
  parentStepId: null
2282
2146
  });
2283
2147
  await state.storage.deleteAlarm();
2284
-
2285
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2286
- type: "sleep",
2148
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2287
2149
  wakeAt: pastWakeAt,
2288
2150
  parentStepId: null
2289
2151
  });
2290
2152
  expect(await state.storage.getAlarm()).toBeNull();
2291
2153
  });
2292
2154
  });
2293
-
2294
- it("does not append a second sleep_waiting step_events row when the sleep step already exists", async () => {
2295
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2296
- await runInDurableObject(stub, async (instance, state) => {
2297
- const context = new WorkflowRuntimeContext(state.storage);
2298
- const wakeAt = new Date(Date.now() + 30_000);
2299
- await context.getOrCreateStep(createSleepStepId("sleep-1"), { type: "sleep", wakeAt, parentStepId: null });
2300
- await context.getOrCreateStep(createSleepStepId("sleep-1"), { type: "sleep", wakeAt, parentStepId: null });
2301
- const events = await instance.getStepEvents_experimental();
2302
- expect(events).toHaveLength(1);
2303
- expect(events[0]).toMatchObject({
2304
- type: "sleep_waiting",
2305
- stepId: "sleep-1",
2306
- wakeAt: wakeAt,
2307
- recordedAt: expect.any(Date)
2308
- });
2309
- });
2310
- });
2311
-
2312
- it("does not set an alarm when re-reading an already elapsed sleep step", async () => {
2313
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2314
- await runInDurableObject(stub, async (_instance, state) => {
2315
- const context = new WorkflowRuntimeContext(state.storage);
2316
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2317
- type: "sleep",
2318
- wakeAt: new Date(Date.now() + 60_000),
2319
- parentStepId: null
2320
- });
2321
- await state.storage.deleteAlarm();
2322
- const now = Date.now();
2323
- state.storage.sql.exec(
2324
- "UPDATE steps SET state = 'elapsed', wake_at = NULL, resolved_at = ? WHERE id = 'sleep-1'",
2325
- now
2326
- );
2327
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2328
- type: "sleep",
2329
- wakeAt: new Date(),
2330
- parentStepId: null
2331
- });
2332
- expect(await state.storage.getAlarm()).toBeNull();
2333
- });
2334
- });
2335
2155
  });
2336
2156
 
2337
- describe("handleSleepStepEvent({ type: 'elapsed' })", () => {
2157
+ describe("handleSleepStepElapsed()", () => {
2338
2158
  it("moves a sleep step from 'waiting' to 'elapsed'", async () => {
2339
2159
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2340
2160
  await runInDurableObject(stub, async (_instance, state) => {
2341
2161
  const context = new WorkflowRuntimeContext(state.storage);
2342
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2343
- type: "sleep",
2162
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2344
2163
  wakeAt: new Date(),
2345
2164
  parentStepId: null
2346
2165
  });
2347
- context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2348
- const updatedStep = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2349
- type: "sleep",
2166
+ context.handleSleepStepElapsed(createSleepStepId("sleep-1"));
2167
+ const step = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2350
2168
  wakeAt: new Date(),
2351
2169
  parentStepId: null
2352
2170
  });
2353
- expect(updatedStep).toMatchObject({
2171
+ expect(step).toMatchObject({
2354
2172
  state: "elapsed",
2355
2173
  resolvedAt: expect.any(Date)
2356
2174
  });
2357
2175
  });
2358
2176
  });
2359
2177
 
2360
- it("writes a 'sleep_elapsed' step event when a sleep step is elapsed", async () => {
2361
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2362
- await runInDurableObject(stub, async (instance, state) => {
2363
- const context = new WorkflowRuntimeContext(state.storage);
2364
- const wakeAt = new Date(Date.now() + 60_000);
2365
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2366
- type: "sleep",
2367
- wakeAt: wakeAt,
2368
- parentStepId: null
2369
- });
2370
- context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2371
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2372
- {
2373
- type: "sleep_waiting",
2374
- stepId: "sleep-1",
2375
- wakeAt: wakeAt,
2376
- recordedAt: expect.any(Date)
2377
- },
2378
- {
2379
- type: "sleep_elapsed",
2380
- stepId: "sleep-1",
2381
- recordedAt: expect.any(Date)
2382
- }
2383
- ]);
2384
- });
2385
- });
2386
-
2387
2178
  it("throws when the step does not exist", async () => {
2388
2179
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2389
2180
  await runInDurableObject(stub, async (_instance, state) => {
2390
2181
  const context = new WorkflowRuntimeContext(state.storage);
2391
- expect(() => context.handleSleepStepEvent(createSleepStepId("nonexistent"), { type: "elapsed" })).toThrow(
2392
- /not found/
2393
- );
2182
+ expect(() => context.handleSleepStepElapsed(createSleepStepId("nonexistent"))).toThrow(/not found/);
2394
2183
  });
2395
2184
  });
2396
2185
 
@@ -2398,13 +2187,12 @@ describe("WorkflowRuntime", () => {
2398
2187
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2399
2188
  await runInDurableObject(stub, async (_instance, state) => {
2400
2189
  const context = new WorkflowRuntimeContext(state.storage);
2401
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2402
- type: "sleep",
2403
- wakeAt: new Date(),
2190
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2191
+ wakeAt: new Date(Date.now() + 60_000),
2404
2192
  parentStepId: null
2405
2193
  });
2406
- context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2407
- expect(() => context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" })).toThrow(
2194
+ context.handleSleepStepElapsed(createSleepStepId("sleep-1"));
2195
+ expect(() => context.handleSleepStepElapsed(createSleepStepId("sleep-1"))).toThrow(
2408
2196
  /Expected 'waiting' but got elapsed/
2409
2197
  );
2410
2198
  });
@@ -2413,13 +2201,12 @@ describe("WorkflowRuntime", () => {
2413
2201
  });
2414
2202
 
2415
2203
  describe("wait steps", () => {
2416
- describe("getOrCreateStep()", () => {
2204
+ describe("getOrCreateWaitStep()", () => {
2417
2205
  it("creates a wait step when no timeout is provided", async () => {
2418
2206
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2419
2207
  await runInDurableObject(stub, async (_instance, state) => {
2420
2208
  const context = new WorkflowRuntimeContext(state.storage);
2421
- const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2422
- type: "wait",
2209
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2423
2210
  eventName: "event-1",
2424
2211
  parentStepId: null
2425
2212
  });
@@ -2437,371 +2224,131 @@ describe("WorkflowRuntime", () => {
2437
2224
  await runInDurableObject(stub, async (_instance, state) => {
2438
2225
  const context = new WorkflowRuntimeContext(state.storage);
2439
2226
  const timeoutAt = new Date(Date.now() + 60_000);
2440
- const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2441
- type: "wait",
2227
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2442
2228
  eventName: "event-1",
2443
2229
  parentStepId: null,
2444
- timeoutAt: timeoutAt
2230
+ timeoutAt
2445
2231
  });
2446
2232
  expect(step).toMatchObject({
2447
2233
  id: "wait-1",
2448
2234
  type: "wait",
2449
2235
  state: "waiting",
2450
2236
  eventName: "event-1",
2451
- timeoutAt: timeoutAt
2237
+ timeoutAt
2452
2238
  });
2453
2239
  });
2454
2240
  });
2455
2241
 
2456
- it("writes a 'wait_waiting' step event", async () => {
2457
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2458
- await runInDurableObject(stub, async (instance, state) => {
2459
- const context = new WorkflowRuntimeContext(state.storage);
2460
- const timeoutAt = new Date(Date.now() + 60_000);
2461
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2462
- type: "wait",
2463
- eventName: "event-1",
2464
- parentStepId: null,
2465
- timeoutAt: timeoutAt
2466
- });
2467
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2468
- {
2469
- type: "wait_waiting",
2470
- stepId: "wait-1",
2471
- eventName: "event-1",
2472
- timeoutAt: timeoutAt,
2473
- recordedAt: expect.any(Date)
2474
- }
2475
- ]);
2476
- });
2477
- });
2478
-
2479
2242
  it("creates a wait step once and returns the same durable row on subsequent reads", async () => {
2480
2243
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2481
2244
  await runInDurableObject(stub, async (_instance, state) => {
2482
2245
  const context = new WorkflowRuntimeContext(state.storage);
2483
- const first = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2484
- type: "wait",
2246
+ const first = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2485
2247
  eventName: "event-1",
2486
- parentStepId: null,
2487
- timeoutAt: new Date(Date.now() + 60_000)
2248
+ parentStepId: null
2488
2249
  });
2489
- const second = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2490
- type: "wait",
2250
+ const second = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2491
2251
  eventName: "event-1",
2492
- parentStepId: null,
2493
- timeoutAt: new Date(Date.now() + 60_000)
2252
+ parentStepId: null
2494
2253
  });
2495
2254
  expect(first).toEqual(second);
2496
2255
  });
2497
2256
  });
2498
2257
 
2499
- it("schedules an alarm when a timeout is provided", async () => {
2500
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2501
- await runInDurableObject(stub, async (_instance, state) => {
2502
- const context = new WorkflowRuntimeContext(state.storage);
2503
- const timeoutAt = new Date(Date.now() + 60_000);
2504
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2505
- type: "wait",
2506
- eventName: "event-1",
2507
- parentStepId: null,
2508
- timeoutAt: timeoutAt
2509
- });
2510
- expect(await state.storage.getAlarm()).toBe(timeoutAt.getTime());
2511
- });
2512
- });
2513
-
2514
- it("replaces any existing alarm with a new alarm when a timeout is provided", async () => {
2258
+ it("does not set a durable object alarm by itself", async () => {
2515
2259
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2516
2260
  await runInDurableObject(stub, async (_instance, state) => {
2517
- await state.storage.setAlarm(Date.now() + 999_999);
2518
-
2519
2261
  const context = new WorkflowRuntimeContext(state.storage);
2520
- const timeoutAt = new Date(Date.now() + 60_000);
2521
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2522
- type: "wait",
2262
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2523
2263
  eventName: "event-1",
2524
2264
  parentStepId: null,
2525
- timeoutAt: timeoutAt
2526
- });
2527
- expect(await state.storage.getAlarm()).toBe(timeoutAt.getTime());
2528
- });
2529
- });
2530
-
2531
- it("doesn't schedule an alarm when no timeout is provided", async () => {
2532
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2533
- await runInDurableObject(stub, async (_instance, state) => {
2534
- const context = new WorkflowRuntimeContext(state.storage);
2535
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2536
- type: "wait",
2537
- eventName: "event-1",
2538
- parentStepId: null
2265
+ timeoutAt: new Date(Date.now() + 60_000)
2539
2266
  });
2540
2267
  expect(await state.storage.getAlarm()).toBeNull();
2541
2268
  });
2542
2269
  });
2543
2270
 
2544
- it("uses the stored timeout_at for the alarm, not the caller-provided timeoutAt", async () => {
2271
+ it("leaves an existing alarm unchanged when creating a wait step with timeout", async () => {
2545
2272
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2546
2273
  await runInDurableObject(stub, async (_instance, state) => {
2274
+ const prior = Date.now() + 999_999;
2275
+ await state.storage.setAlarm(prior);
2547
2276
  const context = new WorkflowRuntimeContext(state.storage);
2548
- const originalTimeout = new Date(Date.now() + 60_000);
2549
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2550
- type: "wait",
2277
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2551
2278
  eventName: "event-1",
2552
2279
  parentStepId: null,
2553
- timeoutAt: originalTimeout
2554
- });
2555
- await state.storage.deleteAlarm();
2556
-
2557
- const shiftedTimeout = new Date(Date.now() + 120_000);
2558
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2559
- type: "wait",
2560
- eventName: "event-1",
2561
- parentStepId: null,
2562
- timeoutAt: shiftedTimeout
2280
+ timeoutAt: new Date(Date.now() + 60_000)
2563
2281
  });
2564
- expect(await state.storage.getAlarm()).toBe(originalTimeout.getTime());
2282
+ expect(await state.storage.getAlarm()).toBe(prior);
2565
2283
  });
2566
2284
  });
2567
2285
 
2568
- it("does not schedule an alarm when an existing wait step has a past timeout_at", async () => {
2286
+ it("satisfies from a queued inbound event when creating the wait step", async () => {
2569
2287
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2570
2288
  await runInDurableObject(stub, async (_instance, state) => {
2571
2289
  const context = new WorkflowRuntimeContext(state.storage);
2572
- const pastTimeout = new Date(Date.now() - 10_000);
2573
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2574
- type: "wait",
2575
- eventName: "event-1",
2576
- parentStepId: null,
2577
- timeoutAt: pastTimeout
2578
- });
2579
- await state.storage.deleteAlarm();
2580
-
2581
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2582
- type: "wait",
2583
- eventName: "event-1",
2584
- parentStepId: null,
2585
- timeoutAt: pastTimeout
2586
- });
2587
- expect(await state.storage.getAlarm()).toBeNull();
2588
- });
2589
- });
2590
- });
2591
-
2592
- describe("handleInboundEvent()", () => {
2593
- it("moves a wait step from 'waiting' to 'satisfied' when event is delivered", async () => {
2594
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2595
- await runInDurableObject(stub, async (instance, state) => {
2596
- const context = new WorkflowRuntimeContext(state.storage);
2597
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2598
- type: "wait",
2599
- eventName: "event-1",
2600
- parentStepId: null
2601
- });
2602
- await instance.handleInboundEvent("event-1", "payload");
2603
- const updatedStep = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2604
- type: "wait",
2605
- eventName: "event-1",
2606
- parentStepId: null
2607
- });
2608
- expect(updatedStep).toMatchObject({
2609
- state: "satisfied",
2610
- payload: JSON.stringify("payload"),
2611
- resolvedAt: expect.any(Date)
2612
- });
2613
- });
2614
- });
2615
-
2616
- it("writes a 'wait_satisfied' step event when a wait step is satisfied", async () => {
2617
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2618
- await runInDurableObject(stub, async (instance, state) => {
2619
- const context = new WorkflowRuntimeContext(state.storage);
2620
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2621
- type: "wait",
2622
- eventName: "event-1",
2623
- parentStepId: null
2624
- });
2625
- await instance.handleInboundEvent("event-1", "payload");
2626
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2627
- {
2628
- type: "wait_waiting",
2629
- stepId: "wait-1",
2630
- eventName: "event-1",
2631
- recordedAt: expect.any(Date)
2632
- },
2633
- {
2634
- type: "wait_satisfied",
2635
- stepId: "wait-1",
2636
- payload: JSON.stringify("payload"),
2637
- recordedAt: expect.any(Date)
2638
- }
2639
- ]);
2640
- });
2641
- });
2642
-
2643
- it("queues the event when no matching wait step exists", async () => {
2644
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2645
- await runInDurableObject(stub, async (instance, state) => {
2646
- await instance.handleInboundEvent("event-1", "queued-payload");
2647
- const context = new WorkflowRuntimeContext(state.storage);
2648
- const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2649
- type: "wait",
2290
+ state.storage.sql.exec(
2291
+ `INSERT INTO inbound_events (event_name, payload) VALUES (?, ?)`,
2292
+ "event-1",
2293
+ JSON.stringify({ v: 1 })
2294
+ );
2295
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2650
2296
  eventName: "event-1",
2651
2297
  parentStepId: null
2652
2298
  });
2653
2299
  expect(step).toMatchObject({
2654
2300
  state: "satisfied",
2655
- payload: JSON.stringify("queued-payload")
2656
- });
2657
- });
2658
- });
2659
-
2660
- it("consumes queued inbound events in FIFO order when several waits are created", async () => {
2661
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2662
- await runInDurableObject(stub, async (instance, state) => {
2663
- await instance.handleInboundEvent("event-1", "first");
2664
- // Distinct `created_at` so FIFO ordering does not depend on random `inbound_events.id` when timestamps tie.
2665
- await new Promise((r) => setTimeout(r, 2));
2666
- await instance.handleInboundEvent("event-1", "second");
2667
- const context = new WorkflowRuntimeContext(state.storage);
2668
- const step1 = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2669
- type: "wait",
2670
- eventName: "event-1",
2671
- parentStepId: null
2672
- });
2673
- const step2 = await context.getOrCreateStep(createWaitStepId("wait-2"), {
2674
- type: "wait",
2675
- eventName: "event-1",
2676
- parentStepId: null
2677
- });
2678
- expect(step1).toMatchObject({
2679
- state: "satisfied",
2680
- payload: JSON.stringify("first")
2681
- });
2682
- expect(step2).toMatchObject({
2683
- state: "satisfied",
2684
- payload: JSON.stringify("second")
2301
+ payload: { v: 1 }
2685
2302
  });
2686
2303
  });
2687
2304
  });
2688
2305
 
2689
- it("satisfies the earliest matching wait step when multiple are waiting", async () => {
2306
+ it("rejects a second inbound_events row with the same claimed_by", async () => {
2690
2307
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2691
- await runInDurableObject(stub, async (instance, state) => {
2308
+ await runInDurableObject(stub, async (_instance, state) => {
2692
2309
  const context = new WorkflowRuntimeContext(state.storage);
2693
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2694
- type: "wait",
2695
- eventName: "event-1",
2696
- parentStepId: null
2697
- });
2698
- await context.getOrCreateStep(createWaitStepId("wait-2"), {
2699
- type: "wait",
2700
- eventName: "event-1",
2701
- parentStepId: null
2702
- });
2703
- await instance.handleInboundEvent("event-1", "payload");
2704
- const step1 = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2705
- type: "wait",
2310
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2706
2311
  eventName: "event-1",
2707
2312
  parentStepId: null
2708
2313
  });
2709
- const step2 = await context.getOrCreateStep(createWaitStepId("wait-2"), {
2710
- type: "wait",
2711
- eventName: "event-1",
2712
- parentStepId: null
2713
- });
2714
- expect(step1).toMatchObject({
2715
- state: "satisfied",
2716
- payload: JSON.stringify("payload")
2717
- });
2718
- expect(step2).toMatchObject({ state: "waiting" });
2314
+ const t = Date.now();
2315
+ state.storage.sql.exec(
2316
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at) VALUES (?, ?, ?, ?)`,
2317
+ "event-1",
2318
+ null,
2319
+ "wait-1",
2320
+ t
2321
+ );
2322
+ expect(() =>
2323
+ state.storage.sql.exec(
2324
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at) VALUES (?, ?, ?, ?)`,
2325
+ "event-1",
2326
+ null,
2327
+ "wait-1",
2328
+ t + 1
2329
+ )
2330
+ ).toThrow(/UNIQUE/);
2719
2331
  });
2720
2332
  });
2721
-
2722
- it("does not change steps when the workflow is terminal", async () => {
2723
- const executeSpy = vi
2724
- .spyOn(TestWorkflowDefinition.prototype, "execute")
2725
- .mockImplementation(async function (this: TestWorkflowDefinition) {
2726
- await this.run("step-1", async () => 1);
2727
- });
2728
- try {
2729
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2730
- await runInDurableObject(stub, async (instance) => {
2731
- const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
2732
- instance.onStatusChange_experimental = async (status) => {
2733
- if (status === "running") return;
2734
- resolve(status);
2735
- };
2736
- await instance.create({ definitionVersion: "2026-03-19" });
2737
- await expect(promise).resolves.toBe("completed");
2738
-
2739
- const stepsBefore = instance.getSteps_experimental();
2740
- const eventsBefore = instance.getStepEvents_experimental();
2741
-
2742
- await instance.handleInboundEvent("event-1", { ignored: true });
2743
-
2744
- expect(instance.getSteps_experimental()).toEqual(stepsBefore);
2745
- expect(instance.getStepEvents_experimental()).toEqual(eventsBefore);
2746
- });
2747
- } finally {
2748
- executeSpy.mockRestore();
2749
- }
2750
- });
2751
2333
  });
2752
2334
 
2753
- describe("handleWaitStepEvent({ type: 'timed_out' })", () => {
2335
+ describe("handleWaitStepTimedOut()", () => {
2754
2336
  it("moves a wait step from 'waiting' to 'timed_out'", async () => {
2755
2337
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2756
2338
  await runInDurableObject(stub, async (_instance, state) => {
2757
2339
  const context = new WorkflowRuntimeContext(state.storage);
2758
2340
  const timeoutAt = new Date(Date.now() - 1000);
2759
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2760
- type: "wait",
2341
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2761
2342
  eventName: "event-1",
2762
2343
  parentStepId: null,
2763
- timeoutAt: timeoutAt
2344
+ timeoutAt
2764
2345
  });
2765
- context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2766
- const updatedStep = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2767
- type: "wait",
2346
+ context.handleWaitStepTimedOut(createWaitStepId("wait-1"));
2347
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2768
2348
  eventName: "event-1",
2769
- parentStepId: null,
2770
- timeoutAt: timeoutAt
2771
- });
2772
- expect(updatedStep).toMatchObject({
2773
- state: "timed_out",
2774
- resolvedAt: expect.any(Date)
2775
- });
2776
- });
2777
- });
2778
-
2779
- it("writes a 'wait_timed_out' step event when a wait step is timed out", async () => {
2780
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2781
- await runInDurableObject(stub, async (instance, state) => {
2782
- const context = new WorkflowRuntimeContext(state.storage);
2783
- const timeoutAt = new Date(Date.now() - 1000);
2784
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2785
- type: "wait",
2786
- eventName: "event-1",
2787
- parentStepId: null,
2788
- timeoutAt: timeoutAt
2349
+ parentStepId: null
2789
2350
  });
2790
- context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2791
- expect(await instance.getStepEvents_experimental()).toMatchObject([
2792
- {
2793
- type: "wait_waiting",
2794
- stepId: "wait-1",
2795
- eventName: "event-1",
2796
- timeoutAt: timeoutAt,
2797
- recordedAt: expect.any(Date)
2798
- },
2799
- {
2800
- type: "wait_timed_out",
2801
- stepId: "wait-1",
2802
- recordedAt: expect.any(Date)
2803
- }
2804
- ]);
2351
+ expect(step).toMatchObject({ state: "timed_out" });
2805
2352
  });
2806
2353
  });
2807
2354
 
@@ -2809,9 +2356,7 @@ describe("WorkflowRuntime", () => {
2809
2356
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2810
2357
  await runInDurableObject(stub, async (_instance, state) => {
2811
2358
  const context = new WorkflowRuntimeContext(state.storage);
2812
- expect(() => context.handleWaitStepEvent(createWaitStepId("nonexistent"), { type: "timed_out" })).toThrow(
2813
- /not found/
2814
- );
2359
+ expect(() => context.handleWaitStepTimedOut(createWaitStepId("nonexistent"))).toThrow(/not found/);
2815
2360
  });
2816
2361
  });
2817
2362
 
@@ -2819,14 +2364,13 @@ describe("WorkflowRuntime", () => {
2819
2364
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2820
2365
  await runInDurableObject(stub, async (_instance, state) => {
2821
2366
  const context = new WorkflowRuntimeContext(state.storage);
2822
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2823
- type: "wait",
2367
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2824
2368
  eventName: "event-1",
2825
2369
  parentStepId: null,
2826
2370
  timeoutAt: new Date(Date.now() - 1000)
2827
2371
  });
2828
- context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2829
- expect(() => context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" })).toThrow(
2372
+ context.handleWaitStepTimedOut(createWaitStepId("wait-1"));
2373
+ expect(() => context.handleWaitStepTimedOut(createWaitStepId("wait-1"))).toThrow(
2830
2374
  /Expected 'waiting' but got timed_out/
2831
2375
  );
2832
2376
  });