workerflow 0.1.0 → 0.3.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,
@@ -46,7 +48,7 @@ describe("WorkflowRuntime", () => {
46
48
  resolve(status);
47
49
  };
48
50
 
49
- await instance.create({ definitionVersion: "2026-03-19" });
51
+ await instance.create();
50
52
  await expect(promise).resolves.toBe("failed");
51
53
  });
52
54
  } finally {
@@ -69,7 +71,7 @@ describe("WorkflowRuntime", () => {
69
71
  if (status === "running") return;
70
72
  resolve(status);
71
73
  };
72
- await instance.create({ definitionVersion: "2026-03-19" });
74
+ await instance.create();
73
75
  await expect(promise).resolves.toBe("failed");
74
76
  });
75
77
  } finally {
@@ -94,16 +96,18 @@ describe("WorkflowRuntime", () => {
94
96
  resolve(status);
95
97
  };
96
98
 
97
- await instance.create({ definitionVersion: "2026-03-19" });
99
+ await instance.create();
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 {
@@ -132,16 +136,18 @@ describe("WorkflowRuntime", () => {
132
136
  resolve(status);
133
137
  };
134
138
 
135
- await instance.create({ definitionVersion: "2026-03-19" });
139
+ await instance.create();
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 {
@@ -164,7 +170,7 @@ describe("WorkflowRuntime", () => {
164
170
  resolve(status);
165
171
  };
166
172
 
167
- await instance.create({ definitionVersion: "2026-03-19" });
173
+ await instance.create();
168
174
  await expect(promise).resolves.toBe("failed");
169
175
  });
170
176
  } finally {
@@ -194,15 +200,16 @@ describe("WorkflowRuntime", () => {
194
200
  if (status === "running") return;
195
201
  resolve(status);
196
202
  };
197
- await instance.create({ definitionVersion: "2026-03-19" });
203
+ await instance.create();
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);
@@ -230,7 +237,7 @@ describe("WorkflowRuntime", () => {
230
237
  resolve(status);
231
238
  };
232
239
 
233
- await instance.create({ definitionVersion: "2026-03-19" });
240
+ await instance.create();
234
241
  await expect(promise).resolves.toBe("failed");
235
242
  const steps = instance.getSteps_experimental();
236
243
  expect(steps).toHaveLength(1);
@@ -261,13 +268,21 @@ describe("WorkflowRuntime", () => {
261
268
  resolve(status);
262
269
  };
263
270
 
264
- await instance.create({ definitionVersion: "2026-03-19" });
271
+ await instance.create();
265
272
  await expect(promise).resolves.toBe("completed");
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
  });
@@ -296,11 +311,14 @@ describe("WorkflowRuntime", () => {
296
311
  resolve(status);
297
312
  };
298
313
 
299
- await instance.create({ definitionVersion: "2026-03-19" });
314
+ await instance.create();
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",
@@ -333,7 +351,7 @@ describe("WorkflowRuntime", () => {
333
351
  if (status === "running") return;
334
352
  resolve(status);
335
353
  };
336
- await instance.create({ definitionVersion: "2026-03-19", input });
354
+ await instance.create(input);
337
355
  await expect(promise).resolves.toBe("completed");
338
356
  });
339
357
  expect(received.length).toBeGreaterThanOrEqual(1);
@@ -345,27 +363,80 @@ describe("WorkflowRuntime", () => {
345
363
  }
346
364
  });
347
365
 
348
- it("throws when the workflow is not terminal and definition version is already pinned to a different version", async () => {
366
+ it("does not repin input after the workflow is initialized", async () => {
367
+ const received: unknown[] = [];
349
368
  const executeSpy = vi
350
369
  .spyOn(TestWorkflowDefinition.prototype, "execute")
351
370
  .mockImplementation(async function (this: TestWorkflowDefinition) {
352
- await this.wait("wait-1", "event-never", {
371
+ received.push(this.ctx.props.input);
372
+ await this.wait("wait-1", "event-done", {
353
373
  timeoutAt: Date.now() + 86_400_000
354
374
  });
355
375
  });
356
376
  try {
357
377
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
358
378
  await runInDurableObject(stub, async (instance) => {
359
- const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
379
+ const { resolve: resolveRunning, promise: running } = Promise.withResolvers<WorkflowStatus>();
380
+ const { resolve: resolveDone, promise: done } = Promise.withResolvers<WorkflowStatus>();
360
381
  instance.onStatusChange_experimental = async (status) => {
361
- resolve(status);
382
+ if (status === "running") {
383
+ resolveRunning(status);
384
+ } else {
385
+ resolveDone(status);
386
+ }
362
387
  };
363
- await instance.create({ definitionVersion: "2026-03-19" });
364
- await expect(promise).resolves.toBe("running");
388
+ const input = { key: "original" };
389
+ await instance.create(input);
390
+ await expect(running).resolves.toBe("running");
365
391
 
366
- await expect(instance.create({ definitionVersion: "2026-03-20" })).rejects.toThrow(
367
- "Workflow definition version is already pinned to '2026-03-19' and cannot be changed to '2026-03-20'."
368
- );
392
+ await instance.create({ key: "ignored" });
393
+ await instance.handleInboundEvent("event-done");
394
+ await expect(done).resolves.toBe("completed");
395
+
396
+ expect(received.length).toBeGreaterThanOrEqual(1);
397
+ for (const row of received) {
398
+ expect(row).toEqual(input);
399
+ }
400
+ });
401
+ } finally {
402
+ executeSpy.mockRestore();
403
+ }
404
+ });
405
+
406
+ it("does not repin undefined input after the workflow is initialized", async () => {
407
+ const received: unknown[] = [];
408
+ const executeSpy = vi
409
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
410
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
411
+ received.push(this.ctx.props.input);
412
+ await this.wait("wait-1", "event-done", {
413
+ timeoutAt: Date.now() + 86_400_000
414
+ });
415
+ });
416
+ try {
417
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
418
+ await runInDurableObject(stub, async (instance) => {
419
+ const { resolve: resolveRunning, promise: running } = Promise.withResolvers<WorkflowStatus>();
420
+ const { resolve: resolveDone, promise: done } = Promise.withResolvers<WorkflowStatus>();
421
+ instance.onStatusChange_experimental = async (status) => {
422
+ if (status === "running") {
423
+ resolveRunning(status);
424
+ } else {
425
+ resolveDone(status);
426
+ }
427
+ };
428
+
429
+ await instance.create();
430
+ await expect(running).resolves.toBe("running");
431
+
432
+ await instance.create({ key: "ignored" });
433
+ await instance.handleInboundEvent("event-done");
434
+ await expect(done).resolves.toBe("completed");
435
+
436
+ expect(received.length).toBeGreaterThanOrEqual(1);
437
+ for (const row of received) {
438
+ expect(row).toBeUndefined();
439
+ }
369
440
  });
370
441
  } finally {
371
442
  executeSpy.mockRestore();
@@ -380,11 +451,11 @@ describe("WorkflowRuntime", () => {
380
451
  if (status === "running") return;
381
452
  resolve(status);
382
453
  };
383
- await instance.create({ definitionVersion: "2026-03-19" });
454
+ await instance.create();
384
455
  await expect(promise).resolves.toBe("completed");
385
456
  expect(instance.getStatus()).toBe("completed");
386
457
 
387
- await instance.create({ definitionVersion: "2026-03-20" });
458
+ await instance.create();
388
459
  expect(instance.getStatus()).toBe("completed");
389
460
  });
390
461
  });
@@ -410,30 +481,20 @@ describe("WorkflowRuntime", () => {
410
481
  resolve(status);
411
482
  };
412
483
 
413
- await instance.create({ definitionVersion: "2026-03-19" });
484
+ await instance.create();
414
485
  await expect(promise).resolves.toBe("completed");
415
486
 
416
487
  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);
488
+ for (const id of ["L0", "L1", "L2"] as const) {
489
+ const row = steps.find((s) => s.id === id);
490
+ expect(row?.type).toBe("run");
491
+ const attempts = (row as RunStep & { attempts: RunStepAttempt[] }).attempts;
492
+ expect(attempts.filter((a) => a.state === "failed")).toHaveLength(0);
493
+ expect(attempts[attempts.length - 1]).toMatchObject({ state: "succeeded" });
436
494
  }
495
+ expect(steps.find((s) => s.id === "L0")).toMatchObject({ parentStepId: null });
496
+ expect(steps.find((s) => s.id === "L1")).toMatchObject({ parentStepId: "L0" });
497
+ expect(steps.find((s) => s.id === "L2")).toMatchObject({ parentStepId: "L1" });
437
498
  });
438
499
  } finally {
439
500
  executeSpy.mockRestore();
@@ -458,15 +519,15 @@ describe("WorkflowRuntime", () => {
458
519
  resolve(status);
459
520
  };
460
521
 
461
- await instance.create({ definitionVersion: "2026-03-19" });
522
+ await instance.create();
462
523
  await expect(promise).resolves.toBe("completed");
463
524
 
464
525
  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
- });
526
+ const rootAfter = steps.find((s) => s.id === "root-after");
527
+ expect(rootAfter?.type).toBe("run");
528
+ expect(rootAfter).toMatchObject({ parentStepId: null });
529
+ const raa = (rootAfter as RunStep & { attempts: RunStepAttempt[] }).attempts;
530
+ expect(raa[raa.length - 1]).toMatchObject({ state: "succeeded" });
470
531
  expect(steps.find((s) => s.id === "nest-inner")).toMatchObject({
471
532
  parentStepId: "nest-outer"
472
533
  });
@@ -500,34 +561,19 @@ describe("WorkflowRuntime", () => {
500
561
  resolve(status);
501
562
  };
502
563
 
503
- await instance.create({ definitionVersion: "2026-03-19" });
564
+ await instance.create();
504
565
  await expect(promise).resolves.toBe("completed");
505
566
 
506
567
  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);
568
+ for (const id of ["branch-a", "branch-a-inner", "branch-b", "branch-b-inner"] as const) {
569
+ const row = steps.find((s) => s.id === id);
570
+ expect(row?.type).toBe("run");
571
+ const attempts = (row as RunStep & { attempts: RunStepAttempt[] }).attempts;
572
+ expect(attempts.filter((a) => a.state === "failed")).toHaveLength(0);
573
+ expect(attempts[attempts.length - 1]).toMatchObject({ state: "succeeded" });
574
+ }
575
+ expect(steps.find((s) => s.id === "branch-a-inner")).toMatchObject({ parentStepId: "branch-a" });
576
+ expect(steps.find((s) => s.id === "branch-b-inner")).toMatchObject({ parentStepId: "branch-b" });
531
577
  });
532
578
  } finally {
533
579
  executeSpy.mockRestore();
@@ -556,15 +602,15 @@ describe("WorkflowRuntime", () => {
556
602
  resolve(status);
557
603
  };
558
604
 
559
- await instance.create({ definitionVersion: "2026-03-19" });
605
+ await instance.create();
560
606
  await expect(promise).resolves.toBe("completed");
561
607
 
562
608
  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
- });
609
+ const nbInner = steps.find((s) => s.id === "nested-branch-inner");
610
+ expect(nbInner?.type).toBe("run");
611
+ expect(nbInner).toMatchObject({ parentStepId: "nested-branch" });
612
+ const nbia = (nbInner as RunStep & { attempts: RunStepAttempt[] }).attempts;
613
+ expect(nbia[nbia.length - 1]).toMatchObject({ state: "succeeded" });
568
614
  expect(steps.find((s) => s.id === "parallel-wait-nested")).toMatchObject({
569
615
  type: "wait",
570
616
  state: "waiting"
@@ -592,7 +638,7 @@ describe("WorkflowRuntime", () => {
592
638
  resolve(status);
593
639
  };
594
640
 
595
- await instance.create({ definitionVersion: "2026-03-19" });
641
+ await instance.create();
596
642
  await expect(promise).resolves.toBe("completed");
597
643
 
598
644
  const steps = instance.getSteps_experimental();
@@ -626,10 +672,13 @@ describe("WorkflowRuntime", () => {
626
672
  resolve(status);
627
673
  };
628
674
 
629
- await instance.create({ definitionVersion: "2026-03-19" });
675
+ await instance.create();
630
676
 
631
677
  await expect
632
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "deep-wait")?.state)
678
+ .poll(() => {
679
+ const step = instance.getSteps_experimental().find((s) => s.id === "deep-wait");
680
+ return step?.type === "wait" ? step.state : undefined;
681
+ })
633
682
  .toBe("waiting");
634
683
 
635
684
  const stepsWaiting = instance.getSteps_experimental();
@@ -646,12 +695,12 @@ describe("WorkflowRuntime", () => {
646
695
  type: "wait",
647
696
  parentStepId: "outer-wait",
648
697
  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"
698
+ payload: { ok: true }
654
699
  });
700
+ const outerWait = instance.getSteps_experimental().find((s) => s.id === "outer-wait");
701
+ expect(outerWait?.type).toBe("run");
702
+ const owa = (outerWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
703
+ expect(owa[owa.length - 1]).toMatchObject({ state: "succeeded" });
655
704
  });
656
705
  } finally {
657
706
  executeSpy.mockRestore();
@@ -677,20 +726,18 @@ describe("WorkflowRuntime", () => {
677
726
  resolve(status);
678
727
  };
679
728
 
680
- await instance.create({ definitionVersion: "2026-03-19" });
729
+ await instance.create();
681
730
  await expect(promise).resolves.toBe("failed");
682
731
 
683
732
  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
- });
733
+ const fi = steps.find((s) => s.id === "fail-inner");
734
+ expect(fi?.type).toBe("run");
735
+ const fia = (fi as RunStep & { attempts: RunStepAttempt[] }).attempts;
736
+ expect(fia[fia.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
737
+ const fo = steps.find((s) => s.id === "fail-outer");
738
+ expect(fo?.type).toBe("run");
739
+ const foa = (fo as RunStep & { attempts: RunStepAttempt[] }).attempts;
740
+ expect(foa[foa.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
694
741
  });
695
742
  } finally {
696
743
  executeSpy.mockRestore();
@@ -726,20 +773,19 @@ describe("WorkflowRuntime", () => {
726
773
  resolve(status);
727
774
  };
728
775
 
729
- await instance.create({ definitionVersion: "2026-03-19" });
776
+ await instance.create();
730
777
  await expect(promise).resolves.toBe("completed");
731
778
 
732
779
  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
- });
780
+ const outerRow = instance.getSteps_experimental().find((s) => s.id === "suspend-outer");
781
+ expect(outerRow?.type).toBe("run");
782
+ const oa = (outerRow as RunStep & { attempts: RunStepAttempt[] }).attempts;
783
+ expect(oa.filter((a) => a.state === "failed")).toHaveLength(0);
784
+ expect(oa[oa.length - 1]).toMatchObject({ state: "succeeded" });
785
+ const innerRow = instance.getSteps_experimental().find((s) => s.id === "suspend-inner");
786
+ expect(innerRow?.type).toBe("run");
787
+ const ia = (innerRow as RunStep & { attempts: RunStepAttempt[] }).attempts;
788
+ expect(ia[ia.length - 1]).toMatchObject({ state: "succeeded" });
743
789
  });
744
790
  } finally {
745
791
  executeSpy.mockRestore();
@@ -769,21 +815,24 @@ describe("WorkflowRuntime", () => {
769
815
  resolve(status);
770
816
  };
771
817
 
772
- await instance.create({ definitionVersion: "2026-03-19" });
818
+ await instance.create();
773
819
  await expect(promise).resolves.toBe("failed");
774
820
 
775
821
  const steps = instance.getSteps_experimental();
776
- expect(steps.find((s) => s.id === "ex-inner")).toMatchObject({
777
- type: "run",
822
+ const exInner = steps.find((s) => s.id === "ex-inner");
823
+ expect(exInner?.type).toBe("run");
824
+ const exIa = (exInner as RunStep & { attempts: RunStepAttempt[] }).attempts;
825
+ expect(exIa[exIa.length - 1]).toMatchObject({
778
826
  state: "failed",
779
827
  errorName: "Error",
780
828
  errorMessage: "Error: always fail"
781
829
  });
782
- expect(steps.find((s) => s.id === "ex-outer")).toMatchObject({
783
- type: "run",
830
+ const exOuter = steps.find((s) => s.id === "ex-outer");
831
+ expect(exOuter?.type).toBe("run");
832
+ const exOa = (exOuter as RunStep & { attempts: RunStepAttempt[] }).attempts;
833
+ expect(exOa[exOa.length - 1]).toMatchObject({
784
834
  state: "failed",
785
- errorName: "Error",
786
- errorMessage: "Error"
835
+ errorName: "Error"
787
836
  });
788
837
  });
789
838
  } finally {
@@ -809,16 +858,18 @@ describe("WorkflowRuntime", () => {
809
858
  resolve(status);
810
859
  };
811
860
 
812
- await instance.create({ definitionVersion: "2026-03-19" });
861
+ await instance.create();
813
862
  await expect(promise).resolves.toBe("failed");
814
863
 
815
864
  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",
865
+ const pi = steps.find((s) => s.id === "post-inner");
866
+ expect(pi?.type).toBe("run");
867
+ const pia = (pi as RunStep & { attempts: RunStepAttempt[] }).attempts;
868
+ expect(pia[pia.length - 1]).toMatchObject({ state: "succeeded" });
869
+ const po = steps.find((s) => s.id === "post-outer");
870
+ expect(po?.type).toBe("run");
871
+ const poa = (po as RunStep & { attempts: RunStepAttempt[] }).attempts;
872
+ expect(poa[poa.length - 1]).toMatchObject({
822
873
  state: "failed",
823
874
  errorMessage: expect.stringContaining("outer-only failure")
824
875
  });
@@ -847,23 +898,27 @@ describe("WorkflowRuntime", () => {
847
898
  resolve(status);
848
899
  };
849
900
 
850
- await instance.create({ definitionVersion: "2026-03-19" });
901
+ await instance.create();
851
902
  await expect
852
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "root-deep-wait")?.state)
903
+ .poll(() => {
904
+ const step = instance.getSteps_experimental().find((s) => s.id === "root-deep-wait");
905
+ return step?.type === "wait" ? step.state : undefined;
906
+ })
853
907
  .toBe("waiting");
854
908
 
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);
909
+ const rootRunBefore = instance.getSteps_experimental().find((s) => s.id === "root-wait-run");
910
+ expect(rootRunBefore?.type).toBe("run");
911
+ const rba = (rootRunBefore as RunStep & { attempts: RunStepAttempt[] }).attempts;
912
+ expect(rba.filter((a) => a.state === "failed")).toHaveLength(0);
859
913
 
860
914
  await instance.handleInboundEvent("root-deep-ev", true);
861
915
  await expect(promise).resolves.toBe("completed");
862
916
 
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);
917
+ const rootRunAfter = instance.getSteps_experimental().find((s) => s.id === "root-wait-run");
918
+ expect(rootRunAfter?.type).toBe("run");
919
+ const raa = (rootRunAfter as RunStep & { attempts: RunStepAttempt[] }).attempts;
920
+ expect(raa.filter((a) => a.state === "failed")).toHaveLength(0);
921
+ expect(raa[raa.length - 1]).toMatchObject({ state: "succeeded" });
867
922
  });
868
923
  } finally {
869
924
  executeSpy.mockRestore();
@@ -893,15 +948,15 @@ describe("WorkflowRuntime", () => {
893
948
  resolve(status);
894
949
  };
895
950
 
896
- await instance.create({ definitionVersion: "2026-03-19" });
951
+ await instance.create();
897
952
  await expect(promise).resolves.toBe("completed");
898
953
 
899
954
  const steps = instance.getSteps_experimental();
900
955
  expect(steps).toHaveLength(2);
901
- expect(steps.find((s) => s.id === "parallel-run")).toMatchObject({
902
- type: "run",
903
- state: "succeeded"
904
- });
956
+ const prun = steps.find((s) => s.id === "parallel-run");
957
+ expect(prun?.type).toBe("run");
958
+ const pra = (prun as RunStep & { attempts: RunStepAttempt[] }).attempts;
959
+ expect(pra[pra.length - 1]).toMatchObject({ state: "succeeded" });
905
960
  expect(steps.find((s) => s.id === "parallel-wait")).toMatchObject({
906
961
  type: "wait",
907
962
  state: "waiting"
@@ -932,20 +987,19 @@ describe("WorkflowRuntime", () => {
932
987
  resolve(status);
933
988
  };
934
989
 
935
- await instance.create({ definitionVersion: "2026-03-19" });
990
+ await instance.create();
936
991
  await expect(promise).resolves.toBe("completed");
937
992
 
938
993
  const steps = instance.getSteps_experimental();
939
994
  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
- });
995
+ const pf = steps.find((s) => s.id === "parallel-fail");
996
+ expect(pf?.type).toBe("run");
997
+ const pfa = (pf as RunStep & { attempts: RunStepAttempt[] }).attempts;
998
+ expect(pfa[pfa.length - 1]).toMatchObject({ state: "failed", errorName: "NonRetryableStepError" });
999
+ const pok = steps.find((s) => s.id === "parallel-ok");
1000
+ expect(pok?.type).toBe("run");
1001
+ const poka = (pok as RunStep & { attempts: RunStepAttempt[] }).attempts;
1002
+ expect(poka[poka.length - 1]).toMatchObject({ state: "succeeded" });
949
1003
  });
950
1004
  } finally {
951
1005
  executeSpy.mockRestore();
@@ -977,12 +1031,15 @@ describe("WorkflowRuntime", () => {
977
1031
  terminalStatuses.push(status);
978
1032
  };
979
1033
 
980
- await instance.create({ definitionVersion: "2026-03-19" });
1034
+ await instance.create();
981
1035
  await expect.poll(() => instance.getStatus()).toBe("running");
982
1036
  expect(terminalStatuses).toHaveLength(0);
983
1037
 
984
1038
  await expect
985
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "allsettled-rerun-wait")?.state)
1039
+ .poll(() => {
1040
+ const step = instance.getSteps_experimental().find((s) => s.id === "allsettled-rerun-wait");
1041
+ return step?.type === "wait" ? step.state : undefined;
1042
+ })
986
1043
  .toBe("waiting");
987
1044
 
988
1045
  const steps = instance.getSteps_experimental();
@@ -992,10 +1049,10 @@ describe("WorkflowRuntime", () => {
992
1049
  });
993
1050
  // Unlike `Promise.all`, `allSettled` waits for every branch before returning, so the run can finish
994
1051
  // 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
- });
1052
+ const asRun = steps.find((s) => s.id === "allsettled-rerun-run");
1053
+ expect(asRun?.type).toBe("run");
1054
+ const asra = (asRun as RunStep & { attempts: RunStepAttempt[] }).attempts;
1055
+ expect(asra[asra.length - 1]).toMatchObject({ state: "succeeded" });
999
1056
  });
1000
1057
  } finally {
1001
1058
  executeSpy.mockRestore();
@@ -1024,12 +1081,15 @@ describe("WorkflowRuntime", () => {
1024
1081
  terminalStatuses.push(status);
1025
1082
  };
1026
1083
 
1027
- await instance.create({ definitionVersion: "2026-03-19" });
1084
+ await instance.create();
1028
1085
  await expect.poll(() => instance.getStatus()).toBe("running");
1029
1086
  expect(terminalStatuses).toHaveLength(0);
1030
1087
 
1031
1088
  await expect
1032
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "parallel-wait")?.state)
1089
+ .poll(() => {
1090
+ const step = instance.getSteps_experimental().find((s) => s.id === "parallel-wait");
1091
+ return step?.type === "wait" ? step.state : undefined;
1092
+ })
1033
1093
  .toBe("waiting");
1034
1094
 
1035
1095
  const steps = instance.getSteps_experimental();
@@ -1037,11 +1097,11 @@ describe("WorkflowRuntime", () => {
1037
1097
  type: "wait",
1038
1098
  state: "waiting"
1039
1099
  });
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
- });
1100
+ // `wait()` usually wins the race; the run branch can still be mid-flight so the latest attempt may still be in flight.
1101
+ const parRun = steps.find((s) => s.id === "parallel-run");
1102
+ expect(parRun?.type).toBe("run");
1103
+ const paa = (parRun as RunStep & { attempts: RunStepAttempt[] }).attempts;
1104
+ expect(paa[paa.length - 1]).toMatchObject({ state: "started" });
1045
1105
  });
1046
1106
  } finally {
1047
1107
  executeSpy.mockRestore();
@@ -1058,7 +1118,7 @@ describe("WorkflowRuntime", () => {
1058
1118
  if (status === "running") return;
1059
1119
  resolve(status);
1060
1120
  };
1061
- await instance.create({ definitionVersion: "2026-03-19" });
1121
+ await instance.create();
1062
1122
  await expect(promise).resolves.toBe("completed");
1063
1123
  });
1064
1124
 
@@ -1077,7 +1137,7 @@ describe("WorkflowRuntime", () => {
1077
1137
  if (status === "running") return;
1078
1138
  resolve(status);
1079
1139
  };
1080
- await instance.create({ definitionVersion: "2026-03-19" });
1140
+ await instance.create();
1081
1141
  await expect(promise).resolves.toBe("failed");
1082
1142
  });
1083
1143
 
@@ -1110,10 +1170,13 @@ describe("WorkflowRuntime", () => {
1110
1170
  try {
1111
1171
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1112
1172
  await runInDurableObject(stub, async (instance) => {
1113
- await instance.create({ definitionVersion: "2026-03-19" });
1173
+ await instance.create();
1114
1174
  await expect.poll(() => instance.getStatus()).toBe("running");
1115
1175
  await expect
1116
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1176
+ .poll(() => {
1177
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1178
+ return step?.type === "wait" ? step.state : undefined;
1179
+ })
1117
1180
  .toBe("waiting");
1118
1181
 
1119
1182
  await instance.pause();
@@ -1141,7 +1204,7 @@ describe("WorkflowRuntime", () => {
1141
1204
  if (status === "paused") resolve();
1142
1205
  };
1143
1206
 
1144
- await instance.create({ definitionVersion: "2026-03-19" });
1207
+ await instance.create();
1145
1208
  await instance.pause();
1146
1209
  await promise;
1147
1210
  });
@@ -1169,7 +1232,7 @@ describe("WorkflowRuntime", () => {
1169
1232
  try {
1170
1233
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1171
1234
  await runInDurableObject(stub, async (instance) => {
1172
- await instance.create({ definitionVersion: "2026-03-19" });
1235
+ await instance.create();
1173
1236
  await expect.poll(() => instance.getStatus()).toBe("running");
1174
1237
  await instance.pause();
1175
1238
  expect(instance.getStatus()).toBe("paused");
@@ -1198,7 +1261,7 @@ describe("WorkflowRuntime", () => {
1198
1261
  resolve(status);
1199
1262
  };
1200
1263
 
1201
- await instance.create({ definitionVersion: "2026-03-19" });
1264
+ await instance.create();
1202
1265
  await expect.poll(() => instance.getStatus()).toBe("running");
1203
1266
  await instance.pause();
1204
1267
  expect(instance.getStatus()).toBe("paused");
@@ -1231,7 +1294,7 @@ describe("WorkflowRuntime", () => {
1231
1294
  try {
1232
1295
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1233
1296
  await runInDurableObject(stub, async (instance) => {
1234
- await instance.create({ definitionVersion: "2026-03-19" });
1297
+ await instance.create();
1235
1298
  await expect.poll(() => instance.getStatus()).toBe("running");
1236
1299
  await expect(instance.resume()).rejects.toThrow(
1237
1300
  "Cannot resume workflow: expected status 'paused' but got 'running'."
@@ -1252,10 +1315,13 @@ describe("WorkflowRuntime", () => {
1252
1315
  try {
1253
1316
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1254
1317
  await runInDurableObject(stub, async (instance) => {
1255
- await instance.create({ definitionVersion: "2026-03-19" });
1318
+ await instance.create();
1256
1319
 
1257
1320
  await expect
1258
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1321
+ .poll(() => {
1322
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1323
+ return step?.type === "wait" ? step.state : undefined;
1324
+ })
1259
1325
  .toBe("waiting");
1260
1326
 
1261
1327
  await instance.pause();
@@ -1293,9 +1359,12 @@ describe("WorkflowRuntime", () => {
1293
1359
  resolve(status);
1294
1360
  };
1295
1361
 
1296
- await instance.create({ definitionVersion: "2026-03-19" });
1362
+ await instance.create();
1297
1363
  await expect
1298
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1364
+ .poll(() => {
1365
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1366
+ return step?.type === "wait" ? step.state : undefined;
1367
+ })
1299
1368
  .toBe("waiting");
1300
1369
  await instance.pause();
1301
1370
 
@@ -1307,7 +1376,7 @@ describe("WorkflowRuntime", () => {
1307
1376
  expect(instance.getSteps_experimental().find((s) => s.id === "wait-1")).toMatchObject({
1308
1377
  type: "wait",
1309
1378
  state: "satisfied",
1310
- payload: JSON.stringify({ data: "test" })
1379
+ payload: { data: "test" }
1311
1380
  });
1312
1381
  });
1313
1382
  } finally {
@@ -1327,10 +1396,13 @@ describe("WorkflowRuntime", () => {
1327
1396
  try {
1328
1397
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1329
1398
  await runInDurableObject(stub, async (instance) => {
1330
- await instance.create({ definitionVersion: "2026-03-19" });
1399
+ await instance.create();
1331
1400
  await expect.poll(() => instance.getStatus()).toBe("running");
1332
1401
  await expect
1333
- .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1402
+ .poll(() => {
1403
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-1");
1404
+ return step?.type === "wait" ? step.state : undefined;
1405
+ })
1334
1406
  .toBe("waiting");
1335
1407
 
1336
1408
  await instance.pause();
@@ -1348,6 +1420,235 @@ describe("WorkflowRuntime", () => {
1348
1420
  });
1349
1421
  });
1350
1422
 
1423
+ describe("handleInboundEvent()", () => {
1424
+ it("persists satisfied wait payload on inbound_events with claimed_by pointing at the wait step", async () => {
1425
+ const executeSpy = vi
1426
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1427
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1428
+ await this.wait("wait-inbound-row", "evt-claim", {
1429
+ timeoutAt: Date.now() + 86_400_000
1430
+ });
1431
+ });
1432
+
1433
+ try {
1434
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1435
+ await runInDurableObject(stub, async (instance, state) => {
1436
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1437
+ instance.onStatusChange_experimental = async (status) => {
1438
+ if (status === "running") return;
1439
+ resolve(status);
1440
+ };
1441
+
1442
+ await instance.create();
1443
+
1444
+ await expect
1445
+ .poll(() => {
1446
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-inbound-row");
1447
+ return step?.type === "wait" ? step.state : undefined;
1448
+ })
1449
+ .toBe("waiting");
1450
+
1451
+ await instance.handleInboundEvent("evt-claim", { trace: "x" });
1452
+ await expect(promise).resolves.toBe("completed");
1453
+
1454
+ const formatted = instance.getSteps_experimental().find((s) => s.id === "wait-inbound-row");
1455
+ expect(formatted).toMatchObject({
1456
+ type: "wait",
1457
+ state: "satisfied",
1458
+ payload: { trace: "x" }
1459
+ });
1460
+
1461
+ const rows = state.storage.sql
1462
+ .exec<{ payload: string; claimed_by: string | null }>(
1463
+ `SELECT payload, claimed_by FROM inbound_events WHERE claimed_by = ?`,
1464
+ "wait-inbound-row"
1465
+ )
1466
+ .toArray();
1467
+ expect(rows).toHaveLength(1);
1468
+ expect(rows[0]!.claimed_by).toBe("wait-inbound-row");
1469
+ expect(JSON.parse(rows[0]!.payload)).toEqual({ trace: "x" });
1470
+ });
1471
+ } finally {
1472
+ executeSpy.mockRestore();
1473
+ }
1474
+ });
1475
+
1476
+ it("satisfies a waiting wait step when called without a payload", async () => {
1477
+ const executeSpy = vi
1478
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1479
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1480
+ const payload = await this.wait<undefined>("wait-no-payload", "evt");
1481
+ await this.run("after-wait-no-payload", async () => payload);
1482
+ });
1483
+
1484
+ try {
1485
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1486
+ await runInDurableObject(stub, async (instance) => {
1487
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1488
+ instance.onStatusChange_experimental = async (status) => {
1489
+ if (status === "running") return;
1490
+ resolve(status);
1491
+ };
1492
+
1493
+ await instance.create();
1494
+
1495
+ await expect
1496
+ .poll(() => {
1497
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-no-payload");
1498
+ return step?.type === "wait" ? step.state : undefined;
1499
+ })
1500
+ .toBe("waiting");
1501
+
1502
+ await instance.handleInboundEvent("evt");
1503
+ await expect(promise).resolves.toBe("completed");
1504
+
1505
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-no-payload")).toMatchObject({
1506
+ type: "wait",
1507
+ state: "satisfied",
1508
+ payload: undefined
1509
+ });
1510
+
1511
+ const afterWait = instance.getSteps_experimental().find((s) => s.id === "after-wait-no-payload");
1512
+ expect(afterWait?.type).toBe("run");
1513
+ const attempts = (afterWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
1514
+ expect(attempts[attempts.length - 1]).toMatchObject({
1515
+ state: "succeeded",
1516
+ resultType: "none"
1517
+ });
1518
+ });
1519
+ } finally {
1520
+ executeSpy.mockRestore();
1521
+ }
1522
+ });
1523
+
1524
+ it("satisfies a waiting wait step when called with an explicit null payload", async () => {
1525
+ const executeSpy = vi
1526
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1527
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1528
+ const payload = await this.wait<null>("wait-null", "evt");
1529
+ await this.run("after-wait-null", async () => payload);
1530
+ });
1531
+
1532
+ try {
1533
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1534
+ await runInDurableObject(stub, async (instance) => {
1535
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1536
+ instance.onStatusChange_experimental = async (status) => {
1537
+ if (status === "running") return;
1538
+ resolve(status);
1539
+ };
1540
+
1541
+ await instance.create();
1542
+
1543
+ await expect
1544
+ .poll(() => {
1545
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-null");
1546
+ return step?.type === "wait" ? step.state : undefined;
1547
+ })
1548
+ .toBe("waiting");
1549
+
1550
+ await instance.handleInboundEvent("evt", null);
1551
+ await expect(promise).resolves.toBe("completed");
1552
+
1553
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-null")).toMatchObject({
1554
+ type: "wait",
1555
+ state: "satisfied",
1556
+ payload: null
1557
+ });
1558
+
1559
+ const afterWait = instance.getSteps_experimental().find((s) => s.id === "after-wait-null");
1560
+ expect(afterWait?.type).toBe("run");
1561
+ const attempts = (afterWait as RunStep & { attempts: RunStepAttempt[] }).attempts;
1562
+ expect(attempts[attempts.length - 1]).toMatchObject({
1563
+ state: "succeeded",
1564
+ resultType: "json",
1565
+ resultJson: "null"
1566
+ });
1567
+ });
1568
+ } finally {
1569
+ executeSpy.mockRestore();
1570
+ }
1571
+ });
1572
+
1573
+ it("satisfies a waiting wait step via queued payloadless event claimed on resume", async () => {
1574
+ const executeSpy = vi
1575
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1576
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1577
+ await this.wait("wait-queued", "evt");
1578
+ });
1579
+
1580
+ try {
1581
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1582
+ await runInDurableObject(stub, async (instance) => {
1583
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1584
+ instance.onStatusChange_experimental = async (status) => {
1585
+ if (status === "running" || status === "paused") return;
1586
+ resolve(status);
1587
+ };
1588
+
1589
+ await instance.create();
1590
+
1591
+ await expect
1592
+ .poll(() => {
1593
+ const step = instance.getSteps_experimental().find((s) => s.id === "wait-queued");
1594
+ return step?.type === "wait" ? step.state : undefined;
1595
+ })
1596
+ .toBe("waiting");
1597
+
1598
+ await instance.pause();
1599
+
1600
+ // Queue the event without a payload while paused
1601
+ await instance.handleInboundEvent("evt");
1602
+
1603
+ // The wait step should still be waiting (event was only queued, not claimed)
1604
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-queued")).toMatchObject({
1605
+ type: "wait",
1606
+ state: "waiting"
1607
+ });
1608
+
1609
+ await instance.resume();
1610
+ await expect(promise).resolves.toBe("completed");
1611
+
1612
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-queued")).toMatchObject({
1613
+ type: "wait",
1614
+ state: "satisfied",
1615
+ payload: undefined
1616
+ });
1617
+ });
1618
+ } finally {
1619
+ executeSpy.mockRestore();
1620
+ }
1621
+ });
1622
+
1623
+ it("is a no-op when the workflow is in a terminal state", async () => {
1624
+ const executeSpy = vi
1625
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1626
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1627
+ await this.run("step-1", async () => "done");
1628
+ });
1629
+
1630
+ try {
1631
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1632
+ await runInDurableObject(stub, async (instance) => {
1633
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1634
+ instance.onStatusChange_experimental = async (status) => {
1635
+ if (status === "running") return;
1636
+ resolve(status);
1637
+ };
1638
+
1639
+ await instance.create();
1640
+ await expect(promise).resolves.toBe("completed");
1641
+
1642
+ // Should not throw even though there is no matching wait step
1643
+ await instance.handleInboundEvent("any-event", { data: 1 });
1644
+ expect(instance.getStatus()).toBe("completed");
1645
+ });
1646
+ } finally {
1647
+ executeSpy.mockRestore();
1648
+ }
1649
+ });
1650
+ });
1651
+
1351
1652
  describe("getWorkflowEvents_experimental()", () => {
1352
1653
  it("records 'created' when workflow is first initialized", async () => {
1353
1654
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
@@ -1361,7 +1662,7 @@ describe("WorkflowRuntime", () => {
1361
1662
  });
1362
1663
  });
1363
1664
 
1364
- it("records 'started' when workflow transitions from pending to running", async () => {
1665
+ it("records 'started' when workflow transitions from initialized to running", async () => {
1365
1666
  const executeSpy = vi
1366
1667
  .spyOn(TestWorkflowDefinition.prototype, "execute")
1367
1668
  .mockImplementation(async function (this: TestWorkflowDefinition) {
@@ -1377,7 +1678,7 @@ describe("WorkflowRuntime", () => {
1377
1678
  resolve(status);
1378
1679
  };
1379
1680
 
1380
- await instance.create({ definitionVersion: "2026-03-19" });
1681
+ await instance.create();
1381
1682
  await expect(promise).resolves.toBe("completed");
1382
1683
 
1383
1684
  const events = instance.getWorkflowEvents_experimental();
@@ -1398,7 +1699,7 @@ describe("WorkflowRuntime", () => {
1398
1699
  try {
1399
1700
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1400
1701
  await runInDurableObject(stub, async (instance) => {
1401
- await instance.create({ definitionVersion: "2026-03-19" });
1702
+ await instance.create();
1402
1703
  await expect.poll(() => instance.getStatus()).toBe("running");
1403
1704
  await instance.pause();
1404
1705
 
@@ -1426,7 +1727,7 @@ describe("WorkflowRuntime", () => {
1426
1727
  resolve(status);
1427
1728
  };
1428
1729
 
1429
- await instance.create({ definitionVersion: "2026-03-19" });
1730
+ await instance.create();
1430
1731
  await expect.poll(() => instance.getStatus()).toBe("running");
1431
1732
  await instance.pause();
1432
1733
  await instance.resume();
@@ -1458,7 +1759,7 @@ describe("WorkflowRuntime", () => {
1458
1759
  resolve(status);
1459
1760
  };
1460
1761
 
1461
- await instance.create({ definitionVersion: "2026-03-19" });
1762
+ await instance.create();
1462
1763
  await expect(promise).resolves.toBe("failed");
1463
1764
 
1464
1765
  const events = instance.getWorkflowEvents_experimental();
@@ -1479,7 +1780,7 @@ describe("WorkflowRuntime", () => {
1479
1780
  try {
1480
1781
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1481
1782
  await runInDurableObject(stub, async (instance) => {
1482
- await instance.create({ definitionVersion: "2026-03-19" });
1783
+ await instance.create();
1483
1784
  await expect.poll(() => instance.getStatus()).toBe("running");
1484
1785
  await instance.cancel("user requested cancellation");
1485
1786
 
@@ -1515,18 +1816,19 @@ describe("WorkflowRuntime", () => {
1515
1816
 
1516
1817
  describe("WorkflowRuntimeContext", () => {
1517
1818
  describe("run steps", () => {
1518
- describe("getOrCreateStep()", () => {
1819
+ describe("getOrCreateRunStep()", () => {
1519
1820
  it("creates a new run step", async () => {
1520
1821
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1521
1822
  await runInDurableObject(stub, async (_instance, state) => {
1522
1823
  const context = new WorkflowRuntimeContext(state.storage);
1523
- const step = await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1824
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1524
1825
  expect(step).toMatchObject({
1525
1826
  id: "step-1",
1526
1827
  type: "run",
1527
- state: "pending",
1528
- attemptCount: 0
1828
+ maxAttempts: 3,
1829
+ parentStepId: null
1529
1830
  });
1831
+ expect(step.attempts).toEqual([]);
1530
1832
  });
1531
1833
  });
1532
1834
 
@@ -1534,25 +1836,19 @@ describe("WorkflowRuntime", () => {
1534
1836
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1535
1837
  await runInDurableObject(stub, async (_instance, state) => {
1536
1838
  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
- });
1839
+ const first = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1840
+ const second = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1545
1841
  expect(first).toEqual(second);
1546
1842
  });
1547
1843
  });
1548
1844
 
1549
- it("does not write an 'attempt_started' step event when a run step is already in progress", async () => {
1845
+ it("leaves attempts empty until handleRunAttemptStarted", async () => {
1550
1846
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1551
- await runInDurableObject(stub, async (instance, state) => {
1847
+ await runInDurableObject(stub, async (_instance, state) => {
1552
1848
  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([]);
1849
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1850
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1851
+ expect(step.attempts).toEqual([]);
1556
1852
  });
1557
1853
  });
1558
1854
 
@@ -1560,8 +1856,7 @@ describe("WorkflowRuntime", () => {
1560
1856
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1561
1857
  await runInDurableObject(stub, async (_instance, state) => {
1562
1858
  const context = new WorkflowRuntimeContext(state.storage);
1563
- const step = await context.getOrCreateStep(createRunStepId("step-1"), {
1564
- type: "run",
1859
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), {
1565
1860
  maxAttempts: 5,
1566
1861
  parentStepId: null
1567
1862
  });
@@ -1574,14 +1869,14 @@ describe("WorkflowRuntime", () => {
1574
1869
  });
1575
1870
  });
1576
1871
 
1577
- describe("hasRunningOrWaitingChildSteps()", () => {
1872
+ describe("hasInProgressChildSteps()", () => {
1578
1873
  it("returns false when the run step has no direct child rows", async () => {
1579
1874
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1580
1875
  await runInDurableObject(stub, async (_instance, state) => {
1581
1876
  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);
1877
+ context.getOrCreateRunStep(createRunStepId("leaf"), { parentStepId: null });
1878
+ context.handleRunAttemptStarted(createRunStepId("leaf"));
1879
+ expect(context.hasInProgressChildSteps(createRunStepId("leaf"))).toBe(false);
1585
1880
  });
1586
1881
  });
1587
1882
 
@@ -1589,13 +1884,12 @@ describe("WorkflowRuntime", () => {
1589
1884
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1590
1885
  await runInDurableObject(stub, async (_instance, state) => {
1591
1886
  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",
1887
+ context.getOrCreateRunStep(createRunStepId("parent"), { parentStepId: null });
1888
+ context.handleRunAttemptStarted(createRunStepId("parent"));
1889
+ context.getOrCreateRunStep(createRunStepId("child"), {
1596
1890
  parentStepId: createRunStepId("parent")
1597
1891
  });
1598
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1892
+ expect(context.hasInProgressChildSteps(createRunStepId("parent"))).toBe(true);
1599
1893
  });
1600
1894
  });
1601
1895
 
@@ -1603,14 +1897,13 @@ describe("WorkflowRuntime", () => {
1603
1897
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1604
1898
  await runInDurableObject(stub, async (_instance, state) => {
1605
1899
  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",
1900
+ context.getOrCreateRunStep(createRunStepId("parent"), { parentStepId: null });
1901
+ context.handleRunAttemptStarted(createRunStepId("parent"));
1902
+ context.getOrCreateRunStep(createRunStepId("child"), {
1610
1903
  parentStepId: createRunStepId("parent")
1611
1904
  });
1612
- await context.handleRunAttemptEvent(createRunStepId("child"), { type: "running", attemptCount: 1 });
1613
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1905
+ context.handleRunAttemptStarted(createRunStepId("child"));
1906
+ expect(context.hasInProgressChildSteps(createRunStepId("parent"))).toBe(true);
1614
1907
  });
1615
1908
  });
1616
1909
 
@@ -1618,14 +1911,13 @@ describe("WorkflowRuntime", () => {
1618
1911
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1619
1912
  await runInDurableObject(stub, async (_instance, state) => {
1620
1913
  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",
1914
+ context.getOrCreateRunStep(createRunStepId("gp"), { parentStepId: null });
1915
+ context.handleRunAttemptStarted(createRunStepId("gp"));
1916
+ context.getOrCreateRunStep(createRunStepId("mid"), { parentStepId: createRunStepId("gp") });
1917
+ context.getOrCreateRunStep(createRunStepId("leaf"), {
1626
1918
  parentStepId: createRunStepId("mid")
1627
1919
  });
1628
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("gp"))).resolves.toBe(true);
1920
+ expect(context.hasInProgressChildSteps(createRunStepId("gp"))).toBe(true);
1629
1921
  });
1630
1922
  });
1631
1923
 
@@ -1633,14 +1925,13 @@ describe("WorkflowRuntime", () => {
1633
1925
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1634
1926
  await runInDurableObject(stub, async (_instance, state) => {
1635
1927
  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",
1928
+ context.getOrCreateRunStep(createRunStepId("mid"), { parentStepId: null });
1929
+ context.handleRunAttemptStarted(createRunStepId("mid"));
1930
+ context.getOrCreateRunStep(createRunStepId("leaf"), {
1640
1931
  parentStepId: createRunStepId("mid")
1641
1932
  });
1642
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("mid"))).resolves.toBe(true);
1643
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("leaf"))).resolves.toBe(false);
1933
+ expect(context.hasInProgressChildSteps(createRunStepId("mid"))).toBe(true);
1934
+ expect(context.hasInProgressChildSteps(createRunStepId("leaf"))).toBe(false);
1644
1935
  });
1645
1936
  });
1646
1937
 
@@ -1648,20 +1939,15 @@ describe("WorkflowRuntime", () => {
1648
1939
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1649
1940
  await runInDurableObject(stub, async (_instance, state) => {
1650
1941
  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")
1656
- });
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
1942
+ context.getOrCreateRunStep(createRunStepId("par"), { parentStepId: null });
1943
+ context.handleRunAttemptStarted(createRunStepId("par"));
1944
+ context.getOrCreateRunStep(createRunStepId("bad-child"), {
1945
+ parentStepId: createRunStepId("par"),
1946
+ maxAttempts: 1
1663
1947
  });
1664
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("par"))).resolves.toBe(false);
1948
+ context.handleRunAttemptStarted(createRunStepId("bad-child"));
1949
+ context.handleRunAttemptFailed(createRunStepId("bad-child"), { errorMessage: "x" });
1950
+ expect(context.hasInProgressChildSteps(createRunStepId("par"))).toBe(false);
1665
1951
  });
1666
1952
  });
1667
1953
 
@@ -1669,57 +1955,28 @@ describe("WorkflowRuntime", () => {
1669
1955
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1670
1956
  await runInDurableObject(stub, async (_instance, state) => {
1671
1957
  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,
1958
+ context.getOrCreateRunStep(createRunStepId("run-parent"), { parentStepId: null });
1959
+ context.handleRunAttemptStarted(createRunStepId("run-parent"));
1960
+ context.getOrCreateSleepStep(createSleepStepId("child-sleep"), {
1961
+ wakeAt: new Date(Date.now() + 60_000),
1678
1962
  parentStepId: createRunStepId("run-parent")
1679
1963
  });
1680
- await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("run-parent"))).resolves.toBe(true);
1964
+ expect(context.hasInProgressChildSteps(createRunStepId("run-parent"))).toBe(true);
1681
1965
  });
1682
1966
  });
1683
1967
  });
1684
1968
 
1685
- describe("handleRunAttemptEvent({ type: 'running' })", () => {
1686
- it("moves a run step from 'pending' to 'running'", async () => {
1969
+ describe("handleRunAttemptStarted()", () => {
1970
+ it("inserts a started attempt", async () => {
1687
1971
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1688
1972
  await runInDurableObject(stub, async (_instance, state) => {
1689
1973
  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
- });
1703
- });
1704
- });
1705
-
1706
- it("writes an 'attempt_started' step event when a run step is started", async () => {
1707
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1708
- await runInDurableObject(stub, async (instance, state) => {
1709
- 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
- ]);
1974
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1975
+ const started = context.handleRunAttemptStarted(createRunStepId("step-1"));
1976
+ expect(started).toMatchObject({ state: "started", stepId: "step-1" });
1977
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1978
+ expect(step.attempts).toHaveLength(1);
1979
+ expect(step.attempts[0]).toMatchObject({ state: "started" });
1723
1980
  });
1724
1981
  });
1725
1982
 
@@ -1727,148 +1984,55 @@ describe("WorkflowRuntime", () => {
1727
1984
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1728
1985
  await runInDurableObject(stub, async (_instance, state) => {
1729
1986
  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/);
1736
- });
1737
- });
1738
-
1739
- it("throws when the step is not in 'pending' state", async () => {
1740
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1741
- await runInDurableObject(stub, async (_instance, state) => {
1742
- 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
1747
- });
1748
- await expect(
1749
- context.handleRunAttemptEvent(createRunStepId("step-1"), {
1750
- type: "running",
1751
- attemptCount: 2
1752
- })
1753
- ).rejects.toThrow(/Expected 'pending' but got running/);
1987
+ expect(() => context.handleRunAttemptStarted(createRunStepId("nonexistent"))).toThrow(/not found/);
1754
1988
  });
1755
1989
  });
1756
1990
 
1757
- it("rejects when 'next_attempt_at' is in the future", async () => {
1991
+ it("throws when an attempt is already in progress", async () => {
1758
1992
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1759
1993
  await runInDurableObject(stub, async (_instance, state) => {
1760
1994
  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"
1770
- });
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
- });
1780
- });
1781
-
1782
- it("rejects when 'attemptCount' does not match the expected next attempt", async () => {
1783
- const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1784
- await runInDurableObject(stub, async (_instance, state) => {
1785
- 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/);
1995
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
1996
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
1997
+ expect(() => context.handleRunAttemptStarted(createRunStepId("step-1"))).toThrow(/already in progress/);
1793
1998
  });
1794
1999
  });
1795
2000
  });
1796
2001
 
1797
- describe("handleRunAttemptEvent({ type: 'succeeded' })", () => {
1798
- it("moves a run step from 'running' to 'succeeded'", async () => {
2002
+ describe("handleRunAttemptSucceeded()", () => {
2003
+ it("marks the in-flight attempt succeeded with a json result", async () => {
1799
2004
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1800
2005
  await runInDurableObject(stub, async (_instance, state) => {
1801
2006
  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
2007
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2008
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2009
+ const done = context.handleRunAttemptSucceeded(createRunStepId("step-1"), JSON.stringify(0));
2010
+ expect(done).toMatchObject({
2011
+ state: "succeeded",
2012
+ resultType: "json",
2013
+ resultJson: JSON.stringify(0)
1815
2014
  });
1816
- expect(updatedStep).toMatchObject({
2015
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2016
+ expect(step.attempts).toHaveLength(1);
2017
+ expect(step.attempts[0]).toMatchObject({
1817
2018
  state: "succeeded",
1818
- attemptCount: 1,
1819
- result: JSON.stringify({ value: 0 }),
1820
- resolvedAt: expect.any(Date)
2019
+ resultType: "json",
2020
+ resultJson: JSON.stringify(0)
1821
2021
  });
1822
2022
  });
1823
2023
  });
1824
2024
 
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 () => {
2025
+ it("marks the in-flight attempt succeeded with result_type none", async () => {
1857
2026
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1858
2027
  await runInDurableObject(stub, async (_instance, state) => {
1859
2028
  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
2029
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2030
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2031
+ const done = context.handleRunAttemptSucceeded(createRunStepId("step-1"), null);
2032
+ expect(done).toMatchObject({
2033
+ state: "succeeded",
2034
+ resultType: "none"
1864
2035
  });
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
2036
  });
1873
2037
  });
1874
2038
 
@@ -1876,246 +2040,69 @@ describe("WorkflowRuntime", () => {
1876
2040
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1877
2041
  await runInDurableObject(stub, async (_instance, state) => {
1878
2042
  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/);
2043
+ expect(() => context.handleRunAttemptSucceeded(createRunStepId("nonexistent"), null)).toThrow(/not found/);
1886
2044
  });
1887
2045
  });
1888
2046
 
1889
- it("rejects when the step is still in 'pending' state", async () => {
2047
+ it("throws when no attempt is in progress", async () => {
1890
2048
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1891
2049
  await runInDurableObject(stub, async (_instance, state) => {
1892
2050
  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/);
2051
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2052
+ expect(() => context.handleRunAttemptSucceeded(createRunStepId("step-1"), null)).toThrow(
2053
+ /No attempt in progress/
2054
+ );
1901
2055
  });
1902
2056
  });
1903
2057
  });
1904
2058
 
1905
- describe("handleRunAttemptEvent({ type: 'failed' })", () => {
1906
- it("moves a run step from 'running' to 'failed'", async () => {
2059
+ describe("handleRunAttemptFailed()", () => {
2060
+ it("marks terminal failed when max attempts exhausted", async () => {
1907
2061
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1908
2062
  await runInDurableObject(stub, async (_instance, state) => {
1909
2063
  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({
2064
+ context.getOrCreateRunStep(createRunStepId("step-1"), { maxAttempts: 1, parentStepId: null });
2065
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2066
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "error" });
2067
+ expect(failed).toMatchObject({
1930
2068
  state: "failed",
1931
- attemptCount: 1,
1932
- errorMessage: "error"
2069
+ errorMessage: "error",
2070
+ nextAttemptAt: undefined
1933
2071
  });
2072
+ const step = context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2073
+ expect(step.attempts).toHaveLength(1);
2074
+ expect(step.attempts[0]).toMatchObject({ state: "failed", errorMessage: "error" });
1934
2075
  });
1935
2076
  });
1936
2077
 
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 () => {
2078
+ it("records next_attempt_at when retries remain", async () => {
1974
2079
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1975
2080
  await runInDurableObject(stub, async (_instance, state) => {
1976
2081
  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());
2082
+ const before = Date.now();
2083
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2084
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2085
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "transient" });
2086
+ const after = Date.now();
2087
+ expect(failed.state).toBe("failed");
2088
+ if (failed.state !== "failed") throw new Error("expected failed");
2089
+ expect(failed.nextAttemptAt).toBeDefined();
2090
+ expect(failed.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(before + 250);
2091
+ expect(failed.nextAttemptAt!.getTime()).toBeLessThanOrEqual(after + 500 + 100);
1997
2092
  });
1998
2093
  });
1999
2094
 
2000
- it("moves a run step to 'failed' when 'isNonRetryableStepError' is true and retries are available", async () => {
2095
+ it("marks terminal failed when isNonRetryableStepError is true", async () => {
2001
2096
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2002
2097
  await runInDurableObject(stub, async (_instance, state) => {
2003
2098
  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",
2099
+ context.getOrCreateRunStep(createRunStepId("step-1"), { maxAttempts: 10, parentStepId: null });
2100
+ context.handleRunAttemptStarted(createRunStepId("step-1"));
2101
+ const failed = context.handleRunAttemptFailed(createRunStepId("step-1"), {
2102
+ errorMessage: "x",
2017
2103
  isNonRetryableStepError: true
2018
2104
  });
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/);
2105
+ expect(failed).toMatchObject({ state: "failed", nextAttemptAt: undefined });
2119
2106
  });
2120
2107
  });
2121
2108
 
@@ -2123,80 +2110,41 @@ describe("WorkflowRuntime", () => {
2123
2110
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2124
2111
  await runInDurableObject(stub, async (_instance, state) => {
2125
2112
  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);
2113
+ expect(() => context.handleRunAttemptFailed(createRunStepId("nonexistent"), { errorMessage: "e" })).toThrow(
2114
+ /not found/
2115
+ );
2163
2116
  });
2164
2117
  });
2165
2118
 
2166
- it("rejects when the step is still in 'pending' state", async () => {
2119
+ it("throws when no attempt is in progress", async () => {
2167
2120
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2168
2121
  await runInDurableObject(stub, async (_instance, state) => {
2169
2122
  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/);
2123
+ context.getOrCreateRunStep(createRunStepId("step-1"), { parentStepId: null });
2124
+ expect(() => context.handleRunAttemptFailed(createRunStepId("step-1"), { errorMessage: "bad" })).toThrow(
2125
+ /No attempt in progress/
2126
+ );
2178
2127
  });
2179
2128
  });
2180
2129
  });
2181
2130
  });
2182
2131
 
2183
2132
  describe("sleep steps", () => {
2184
- describe("getOrCreateStep()", () => {
2133
+ describe("getOrCreateSleepStep()", () => {
2185
2134
  it("creates a sleep step", async () => {
2186
2135
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2187
2136
  await runInDurableObject(stub, async (_instance, state) => {
2188
2137
  const context = new WorkflowRuntimeContext(state.storage);
2189
2138
  const wakeAt = new Date(Date.now() + 60_000);
2190
- const step = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2191
- type: "sleep",
2192
- wakeAt: wakeAt,
2139
+ const step = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2140
+ wakeAt,
2193
2141
  parentStepId: null
2194
2142
  });
2195
2143
  expect(step).toMatchObject({
2196
2144
  id: "sleep-1",
2197
2145
  type: "sleep",
2198
2146
  state: "waiting",
2199
- wakeAt: wakeAt
2147
+ wakeAt
2200
2148
  });
2201
2149
  });
2202
2150
  });
@@ -2205,13 +2153,9 @@ describe("WorkflowRuntime", () => {
2205
2153
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2206
2154
  await runInDurableObject(stub, async (_instance, state) => {
2207
2155
  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",
2156
+ const w = new Date();
2157
+ const first = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), { wakeAt: w, parentStepId: null });
2158
+ const second = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2215
2159
  wakeAt: new Date(),
2216
2160
  parentStepId: null
2217
2161
  });
@@ -2219,178 +2163,75 @@ describe("WorkflowRuntime", () => {
2219
2163
  });
2220
2164
  });
2221
2165
 
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 () => {
2166
+ it("does not set a durable object alarm by itself", async () => {
2244
2167
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2245
2168
  await runInDurableObject(stub, async (_instance, state) => {
2246
2169
  const context = new WorkflowRuntimeContext(state.storage);
2247
2170
  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());
2171
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), { wakeAt, parentStepId: null });
2172
+ expect(await state.storage.getAlarm()).toBeNull();
2254
2173
  });
2255
2174
  });
2256
2175
 
2257
- it("replaces any existing alarm with a new alarm", async () => {
2176
+ it("leaves an existing alarm unchanged when creating a sleep step", async () => {
2258
2177
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2259
2178
  await runInDurableObject(stub, async (_instance, state) => {
2260
- await state.storage.setAlarm(Date.now() + 999_999);
2261
-
2179
+ const prior = Date.now() + 999_999;
2180
+ await state.storage.setAlarm(prior);
2262
2181
  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,
2182
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2183
+ wakeAt: new Date(Date.now() + 60_000),
2267
2184
  parentStepId: null
2268
2185
  });
2269
- expect(await state.storage.getAlarm()).toBe(wakeAt.getTime());
2186
+ expect(await state.storage.getAlarm()).toBe(prior);
2270
2187
  });
2271
2188
  });
2272
2189
 
2273
- it("does not schedule an alarm when an existing sleep step has a past wake_at", async () => {
2190
+ it("does not set an alarm when re-reading an existing sleep step after deleteAlarm", async () => {
2274
2191
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2275
2192
  await runInDurableObject(stub, async (_instance, state) => {
2276
2193
  const context = new WorkflowRuntimeContext(state.storage);
2277
2194
  const pastWakeAt = new Date(Date.now() - 10_000);
2278
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2279
- type: "sleep",
2195
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2280
2196
  wakeAt: pastWakeAt,
2281
2197
  parentStepId: null
2282
2198
  });
2283
2199
  await state.storage.deleteAlarm();
2284
-
2285
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2286
- type: "sleep",
2200
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2287
2201
  wakeAt: pastWakeAt,
2288
2202
  parentStepId: null
2289
2203
  });
2290
2204
  expect(await state.storage.getAlarm()).toBeNull();
2291
2205
  });
2292
2206
  });
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
2207
  });
2336
2208
 
2337
- describe("handleSleepStepEvent({ type: 'elapsed' })", () => {
2209
+ describe("handleSleepStepElapsed()", () => {
2338
2210
  it("moves a sleep step from 'waiting' to 'elapsed'", async () => {
2339
2211
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2340
2212
  await runInDurableObject(stub, async (_instance, state) => {
2341
2213
  const context = new WorkflowRuntimeContext(state.storage);
2342
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2343
- type: "sleep",
2214
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2344
2215
  wakeAt: new Date(),
2345
2216
  parentStepId: null
2346
2217
  });
2347
- context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2348
- const updatedStep = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2349
- type: "sleep",
2218
+ context.handleSleepStepElapsed(createSleepStepId("sleep-1"));
2219
+ const step = context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2350
2220
  wakeAt: new Date(),
2351
2221
  parentStepId: null
2352
2222
  });
2353
- expect(updatedStep).toMatchObject({
2223
+ expect(step).toMatchObject({
2354
2224
  state: "elapsed",
2355
2225
  resolvedAt: expect.any(Date)
2356
2226
  });
2357
2227
  });
2358
2228
  });
2359
2229
 
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
2230
  it("throws when the step does not exist", async () => {
2388
2231
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2389
2232
  await runInDurableObject(stub, async (_instance, state) => {
2390
2233
  const context = new WorkflowRuntimeContext(state.storage);
2391
- expect(() => context.handleSleepStepEvent(createSleepStepId("nonexistent"), { type: "elapsed" })).toThrow(
2392
- /not found/
2393
- );
2234
+ expect(() => context.handleSleepStepElapsed(createSleepStepId("nonexistent"))).toThrow(/not found/);
2394
2235
  });
2395
2236
  });
2396
2237
 
@@ -2398,13 +2239,12 @@ describe("WorkflowRuntime", () => {
2398
2239
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2399
2240
  await runInDurableObject(stub, async (_instance, state) => {
2400
2241
  const context = new WorkflowRuntimeContext(state.storage);
2401
- await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2402
- type: "sleep",
2403
- wakeAt: new Date(),
2242
+ context.getOrCreateSleepStep(createSleepStepId("sleep-1"), {
2243
+ wakeAt: new Date(Date.now() + 60_000),
2404
2244
  parentStepId: null
2405
2245
  });
2406
- context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2407
- expect(() => context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" })).toThrow(
2246
+ context.handleSleepStepElapsed(createSleepStepId("sleep-1"));
2247
+ expect(() => context.handleSleepStepElapsed(createSleepStepId("sleep-1"))).toThrow(
2408
2248
  /Expected 'waiting' but got elapsed/
2409
2249
  );
2410
2250
  });
@@ -2413,13 +2253,12 @@ describe("WorkflowRuntime", () => {
2413
2253
  });
2414
2254
 
2415
2255
  describe("wait steps", () => {
2416
- describe("getOrCreateStep()", () => {
2256
+ describe("getOrCreateWaitStep()", () => {
2417
2257
  it("creates a wait step when no timeout is provided", async () => {
2418
2258
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2419
2259
  await runInDurableObject(stub, async (_instance, state) => {
2420
2260
  const context = new WorkflowRuntimeContext(state.storage);
2421
- const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2422
- type: "wait",
2261
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2423
2262
  eventName: "event-1",
2424
2263
  parentStepId: null
2425
2264
  });
@@ -2437,371 +2276,131 @@ describe("WorkflowRuntime", () => {
2437
2276
  await runInDurableObject(stub, async (_instance, state) => {
2438
2277
  const context = new WorkflowRuntimeContext(state.storage);
2439
2278
  const timeoutAt = new Date(Date.now() + 60_000);
2440
- const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2441
- type: "wait",
2279
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2442
2280
  eventName: "event-1",
2443
2281
  parentStepId: null,
2444
- timeoutAt: timeoutAt
2282
+ timeoutAt
2445
2283
  });
2446
2284
  expect(step).toMatchObject({
2447
2285
  id: "wait-1",
2448
2286
  type: "wait",
2449
2287
  state: "waiting",
2450
2288
  eventName: "event-1",
2451
- timeoutAt: timeoutAt
2289
+ timeoutAt
2452
2290
  });
2453
2291
  });
2454
2292
  });
2455
2293
 
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
2294
  it("creates a wait step once and returns the same durable row on subsequent reads", async () => {
2480
2295
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2481
2296
  await runInDurableObject(stub, async (_instance, state) => {
2482
2297
  const context = new WorkflowRuntimeContext(state.storage);
2483
- const first = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2484
- type: "wait",
2298
+ const first = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2485
2299
  eventName: "event-1",
2486
- parentStepId: null,
2487
- timeoutAt: new Date(Date.now() + 60_000)
2300
+ parentStepId: null
2488
2301
  });
2489
- const second = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2490
- type: "wait",
2302
+ const second = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2491
2303
  eventName: "event-1",
2492
- parentStepId: null,
2493
- timeoutAt: new Date(Date.now() + 60_000)
2304
+ parentStepId: null
2494
2305
  });
2495
2306
  expect(first).toEqual(second);
2496
2307
  });
2497
2308
  });
2498
2309
 
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 () => {
2310
+ it("does not set a durable object alarm by itself", async () => {
2515
2311
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2516
2312
  await runInDurableObject(stub, async (_instance, state) => {
2517
- await state.storage.setAlarm(Date.now() + 999_999);
2518
-
2519
2313
  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",
2314
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2523
2315
  eventName: "event-1",
2524
2316
  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
2317
+ timeoutAt: new Date(Date.now() + 60_000)
2539
2318
  });
2540
2319
  expect(await state.storage.getAlarm()).toBeNull();
2541
2320
  });
2542
2321
  });
2543
2322
 
2544
- it("uses the stored timeout_at for the alarm, not the caller-provided timeoutAt", async () => {
2323
+ it("leaves an existing alarm unchanged when creating a wait step with timeout", async () => {
2545
2324
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2546
2325
  await runInDurableObject(stub, async (_instance, state) => {
2326
+ const prior = Date.now() + 999_999;
2327
+ await state.storage.setAlarm(prior);
2547
2328
  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",
2329
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2551
2330
  eventName: "event-1",
2552
2331
  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
2332
+ timeoutAt: new Date(Date.now() + 60_000)
2563
2333
  });
2564
- expect(await state.storage.getAlarm()).toBe(originalTimeout.getTime());
2334
+ expect(await state.storage.getAlarm()).toBe(prior);
2565
2335
  });
2566
2336
  });
2567
2337
 
2568
- it("does not schedule an alarm when an existing wait step has a past timeout_at", async () => {
2338
+ it("satisfies from a queued inbound event when creating the wait step", async () => {
2569
2339
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2570
2340
  await runInDurableObject(stub, async (_instance, state) => {
2571
2341
  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",
2342
+ state.storage.sql.exec(
2343
+ `INSERT INTO inbound_events (event_name, payload) VALUES (?, ?)`,
2344
+ "event-1",
2345
+ JSON.stringify({ v: 1 })
2346
+ );
2347
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2650
2348
  eventName: "event-1",
2651
2349
  parentStepId: null
2652
2350
  });
2653
2351
  expect(step).toMatchObject({
2654
2352
  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")
2353
+ payload: { v: 1 }
2685
2354
  });
2686
2355
  });
2687
2356
  });
2688
2357
 
2689
- it("satisfies the earliest matching wait step when multiple are waiting", async () => {
2358
+ it("rejects a second inbound_events row with the same claimed_by", async () => {
2690
2359
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2691
- await runInDurableObject(stub, async (instance, state) => {
2360
+ await runInDurableObject(stub, async (_instance, state) => {
2692
2361
  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",
2362
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2706
2363
  eventName: "event-1",
2707
2364
  parentStepId: null
2708
2365
  });
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" });
2366
+ const t = Date.now();
2367
+ state.storage.sql.exec(
2368
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at) VALUES (?, ?, ?, ?)`,
2369
+ "event-1",
2370
+ null,
2371
+ "wait-1",
2372
+ t
2373
+ );
2374
+ expect(() =>
2375
+ state.storage.sql.exec(
2376
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at) VALUES (?, ?, ?, ?)`,
2377
+ "event-1",
2378
+ null,
2379
+ "wait-1",
2380
+ t + 1
2381
+ )
2382
+ ).toThrow(/UNIQUE/);
2719
2383
  });
2720
2384
  });
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
2385
  });
2752
2386
 
2753
- describe("handleWaitStepEvent({ type: 'timed_out' })", () => {
2387
+ describe("handleWaitStepTimedOut()", () => {
2754
2388
  it("moves a wait step from 'waiting' to 'timed_out'", async () => {
2755
2389
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2756
2390
  await runInDurableObject(stub, async (_instance, state) => {
2757
2391
  const context = new WorkflowRuntimeContext(state.storage);
2758
2392
  const timeoutAt = new Date(Date.now() - 1000);
2759
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2760
- type: "wait",
2393
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2761
2394
  eventName: "event-1",
2762
2395
  parentStepId: null,
2763
- timeoutAt: timeoutAt
2396
+ timeoutAt
2764
2397
  });
2765
- context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2766
- const updatedStep = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2767
- type: "wait",
2398
+ context.handleWaitStepTimedOut(createWaitStepId("wait-1"));
2399
+ const step = context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2768
2400
  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
2401
+ parentStepId: null
2789
2402
  });
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
- ]);
2403
+ expect(step).toMatchObject({ state: "timed_out" });
2805
2404
  });
2806
2405
  });
2807
2406
 
@@ -2809,9 +2408,7 @@ describe("WorkflowRuntime", () => {
2809
2408
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2810
2409
  await runInDurableObject(stub, async (_instance, state) => {
2811
2410
  const context = new WorkflowRuntimeContext(state.storage);
2812
- expect(() => context.handleWaitStepEvent(createWaitStepId("nonexistent"), { type: "timed_out" })).toThrow(
2813
- /not found/
2814
- );
2411
+ expect(() => context.handleWaitStepTimedOut(createWaitStepId("nonexistent"))).toThrow(/not found/);
2815
2412
  });
2816
2413
  });
2817
2414
 
@@ -2819,14 +2416,13 @@ describe("WorkflowRuntime", () => {
2819
2416
  const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2820
2417
  await runInDurableObject(stub, async (_instance, state) => {
2821
2418
  const context = new WorkflowRuntimeContext(state.storage);
2822
- await context.getOrCreateStep(createWaitStepId("wait-1"), {
2823
- type: "wait",
2419
+ context.getOrCreateWaitStep(createWaitStepId("wait-1"), {
2824
2420
  eventName: "event-1",
2825
2421
  parentStepId: null,
2826
2422
  timeoutAt: new Date(Date.now() - 1000)
2827
2423
  });
2828
- context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2829
- expect(() => context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" })).toThrow(
2424
+ context.handleWaitStepTimedOut(createWaitStepId("wait-1"));
2425
+ expect(() => context.handleWaitStepTimedOut(createWaitStepId("wait-1"))).toThrow(
2830
2426
  /Expected 'waiting' but got timed_out/
2831
2427
  );
2832
2428
  });