workerflow 0.1.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.
@@ -0,0 +1,2837 @@
1
+ import { runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
2
+ import { env } from "cloudflare:workers";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import {
5
+ WorkflowRuntimeContext,
6
+ type RunStepId,
7
+ type SleepStepId,
8
+ type WaitStepId,
9
+ type WorkflowStatus
10
+ } from "../src/runtime";
11
+ import { TestWorkflowDefinition } from "./worker";
12
+ import { NonRetryableStepError } from "../src/definition";
13
+
14
+ function createRunStepId(id: string): RunStepId {
15
+ return id as RunStepId;
16
+ }
17
+ function createSleepStepId(id: string): SleepStepId {
18
+ return id as SleepStepId;
19
+ }
20
+ function createWaitStepId(id: string): WaitStepId {
21
+ return id as WaitStepId;
22
+ }
23
+
24
+ describe("WorkflowRuntime", () => {
25
+ it("constructor() initializes the database and sets the status to pending", async () => {
26
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
27
+ await runInDurableObject(stub, async (instance) => {
28
+ expect(instance.getStatus()).toBe("pending");
29
+ });
30
+ });
31
+
32
+ it("fails when the same step id is reused across run steps in one execution", async () => {
33
+ const executeSpy = vi
34
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
35
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
36
+ await this.run("step-1", async () => 1);
37
+ await this.run("step-1", async () => 2);
38
+ });
39
+
40
+ try {
41
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
42
+ await runInDurableObject(stub, async (instance) => {
43
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
44
+ instance.onStatusChange_experimental = async (status) => {
45
+ if (status === "running") return;
46
+ resolve(status);
47
+ };
48
+
49
+ await instance.create({ definitionVersion: "2026-03-19" });
50
+ await expect(promise).resolves.toBe("failed");
51
+ });
52
+ } finally {
53
+ executeSpy.mockRestore();
54
+ }
55
+ });
56
+
57
+ it("fails when the same step id is reused across run steps and wait steps in one execution", async () => {
58
+ const executeSpy = vi
59
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
60
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
61
+ await this.run("shared-id", async () => 1);
62
+ await this.wait("shared-id", "event-1");
63
+ });
64
+ try {
65
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
66
+ await runInDurableObject(stub, async (instance) => {
67
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
68
+ instance.onStatusChange_experimental = async (status) => {
69
+ if (status === "running") return;
70
+ resolve(status);
71
+ };
72
+ await instance.create({ definitionVersion: "2026-03-19" });
73
+ await expect(promise).resolves.toBe("failed");
74
+ });
75
+ } finally {
76
+ executeSpy.mockRestore();
77
+ }
78
+ });
79
+
80
+ it("fails when a step callback throws 'NonRetryableStepError'", async () => {
81
+ const executeSpy = vi
82
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
83
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
84
+ await this.run("step-1", async () => {
85
+ throw new NonRetryableStepError("This is a non-retryable step error");
86
+ });
87
+ });
88
+ try {
89
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
90
+ await runInDurableObject(stub, async (instance) => {
91
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
92
+ instance.onStatusChange_experimental = async (status) => {
93
+ if (status === "running") return;
94
+ resolve(status);
95
+ };
96
+
97
+ await instance.create({ definitionVersion: "2026-03-19" });
98
+ await expect(promise).resolves.toBe("failed");
99
+ const steps = instance.getSteps_experimental();
100
+ expect(steps).toHaveLength(1);
101
+ expect(steps[0]).toMatchObject({
102
+ type: "run",
103
+ state: "failed",
104
+ errorMessage: "NonRetryableStepError: This is a non-retryable step error",
105
+ errorName: "NonRetryableStepError",
106
+ attemptCount: 1
107
+ });
108
+ });
109
+ } finally {
110
+ executeSpy.mockRestore();
111
+ }
112
+ });
113
+
114
+ it("fails when retries are exhausted on a step", async () => {
115
+ const executeSpy = vi
116
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
117
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
118
+ await this.run(
119
+ "step-1",
120
+ async () => {
121
+ throw new Error("test");
122
+ },
123
+ { maxAttempts: 2 }
124
+ );
125
+ });
126
+ try {
127
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
128
+ await runInDurableObject(stub, async (instance) => {
129
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
130
+ instance.onStatusChange_experimental = async (status) => {
131
+ if (status === "running") return;
132
+ resolve(status);
133
+ };
134
+
135
+ await instance.create({ definitionVersion: "2026-03-19" });
136
+ await expect(promise).resolves.toBe("failed");
137
+ const steps = instance.getSteps_experimental();
138
+ expect(steps).toHaveLength(1);
139
+ expect(steps[0]).toMatchObject({
140
+ type: "run",
141
+ state: "failed",
142
+ errorMessage: "Error: test",
143
+ errorName: "Error",
144
+ attemptCount: 2
145
+ });
146
+ });
147
+ } finally {
148
+ executeSpy.mockRestore();
149
+ }
150
+ });
151
+
152
+ it("fails when execute() throws an unhandled error", async () => {
153
+ const executeSpy = vi
154
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
155
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
156
+ throw new Error("test");
157
+ });
158
+ try {
159
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
160
+ await runInDurableObject(stub, async (instance) => {
161
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
162
+ instance.onStatusChange_experimental = async (status) => {
163
+ if (status === "running") return;
164
+ resolve(status);
165
+ };
166
+
167
+ await instance.create({ definitionVersion: "2026-03-19" });
168
+ await expect(promise).resolves.toBe("failed");
169
+ });
170
+ } finally {
171
+ executeSpy.mockRestore();
172
+ }
173
+ });
174
+
175
+ it("retry workflow completes after a transient failure on the first attempt", async () => {
176
+ let attemptCount = 0;
177
+ const nextSpy = vi.spyOn(TestWorkflowDefinition.prototype, "next");
178
+ const executeSpy = vi
179
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
180
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
181
+ await this.run("step-1", async () => {
182
+ attemptCount++;
183
+ if (attemptCount === 1) {
184
+ throw new Error("transient");
185
+ }
186
+ return "ok";
187
+ });
188
+ });
189
+ try {
190
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
191
+ await runInDurableObject(stub, async (instance) => {
192
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
193
+ instance.onStatusChange_experimental = async (status) => {
194
+ if (status === "running") return;
195
+ resolve(status);
196
+ };
197
+ await instance.create({ definitionVersion: "2026-03-19" });
198
+ await expect(promise).resolves.toBe("completed");
199
+ const steps = instance.getSteps_experimental();
200
+ expect(steps).toHaveLength(1);
201
+ expect(steps[0]).toMatchObject({
202
+ type: "run",
203
+ attemptCount: 2,
204
+ state: "succeeded"
205
+ });
206
+ // First next(): failed attempt yields suspended. Retry alarm: second next() replays `execute()` and completes
207
+ // the successful attempt in the same invocation (no extra immediate loop).
208
+ expect(nextSpy).toHaveBeenCalledTimes(2);
209
+ });
210
+ } finally {
211
+ executeSpy.mockRestore();
212
+ nextSpy.mockRestore();
213
+ }
214
+ });
215
+
216
+ it("wait workflow with an already-passed timeout fails without an inbound event", async () => {
217
+ const executeSpy = vi
218
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
219
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
220
+ await this.wait("wait-1", "event-1", {
221
+ timeoutAt: Date.now() - 60_000
222
+ });
223
+ });
224
+ try {
225
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
226
+ await runInDurableObject(stub, async (instance) => {
227
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
228
+ instance.onStatusChange_experimental = async (status) => {
229
+ if (status === "running") return;
230
+ resolve(status);
231
+ };
232
+
233
+ await instance.create({ definitionVersion: "2026-03-19" });
234
+ await expect(promise).resolves.toBe("failed");
235
+ const steps = instance.getSteps_experimental();
236
+ expect(steps).toHaveLength(1);
237
+ expect(steps[0]).toMatchObject({
238
+ type: "wait",
239
+ state: "timed_out"
240
+ });
241
+ });
242
+ } finally {
243
+ executeSpy.mockRestore();
244
+ }
245
+ });
246
+
247
+ it("runs sequential sibling run() steps across multiple next() calls (one callback budget per level per next())", async () => {
248
+ const nextSpy = vi.spyOn(TestWorkflowDefinition.prototype, "next");
249
+ const executeSpy = vi
250
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
251
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
252
+ await this.run("step-a", async () => 1);
253
+ await this.run("step-b", async () => 2);
254
+ });
255
+ try {
256
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
257
+ await runInDurableObject(stub, async (instance) => {
258
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
259
+ instance.onStatusChange_experimental = async (status) => {
260
+ if (status === "running") return;
261
+ resolve(status);
262
+ };
263
+
264
+ await instance.create({ definitionVersion: "2026-03-19" });
265
+ await expect(promise).resolves.toBe("completed");
266
+
267
+ const steps = instance.getSteps_experimental();
268
+ 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" });
271
+
272
+ expect(nextSpy).toHaveBeenCalledTimes(2);
273
+ });
274
+ } finally {
275
+ nextSpy.mockRestore();
276
+ executeSpy.mockRestore();
277
+ }
278
+ });
279
+
280
+ it("chains a run step and a zero-duration sleep in one next() invocation", async () => {
281
+ const nextSpy = vi.spyOn(TestWorkflowDefinition.prototype, "next");
282
+ const executeSpy = vi
283
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
284
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
285
+ await this.run("before-sleep", async () => {
286
+ await this.sleep("sleep-after-run", 0);
287
+ return 1;
288
+ });
289
+ });
290
+ try {
291
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
292
+ await runInDurableObject(stub, async (instance) => {
293
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
294
+ instance.onStatusChange_experimental = async (status) => {
295
+ if (status === "running") return;
296
+ resolve(status);
297
+ };
298
+
299
+ await instance.create({ definitionVersion: "2026-03-19" });
300
+ await expect(promise).resolves.toBe("completed");
301
+
302
+ const steps = instance.getSteps_experimental();
303
+ expect(steps.find((s) => s.id === "before-sleep")).toMatchObject({ type: "run", state: "succeeded" });
304
+ expect(steps.find((s) => s.id === "sleep-after-run")).toMatchObject({
305
+ type: "sleep",
306
+ state: "elapsed",
307
+ parentStepId: "before-sleep"
308
+ });
309
+ // `sleep(0)` elapses via `ResumeImmediatelyError`, so the runtime runs one follow-up `next()` to finish `execute()`.
310
+ expect(nextSpy).toHaveBeenCalledTimes(2);
311
+ });
312
+ } finally {
313
+ nextSpy.mockRestore();
314
+ executeSpy.mockRestore();
315
+ }
316
+ });
317
+
318
+ describe("create()", () => {
319
+ it("passes input through to the definition props on each execute()", async () => {
320
+ const received: unknown[] = [];
321
+ const executeSpy = vi
322
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
323
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
324
+ received.push(this.ctx.props.input);
325
+ await this.run("step-1", async () => 1);
326
+ });
327
+ try {
328
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
329
+ const input = { key: "value", n: 42 };
330
+ await runInDurableObject(stub, async (instance) => {
331
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
332
+ instance.onStatusChange_experimental = async (status) => {
333
+ if (status === "running") return;
334
+ resolve(status);
335
+ };
336
+ await instance.create({ definitionVersion: "2026-03-19", input });
337
+ await expect(promise).resolves.toBe("completed");
338
+ });
339
+ expect(received.length).toBeGreaterThanOrEqual(1);
340
+ for (const row of received) {
341
+ expect(row).toEqual(input);
342
+ }
343
+ } finally {
344
+ executeSpy.mockRestore();
345
+ }
346
+ });
347
+
348
+ it("throws when the workflow is not terminal and definition version is already pinned to a different version", async () => {
349
+ const executeSpy = vi
350
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
351
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
352
+ await this.wait("wait-1", "event-never", {
353
+ timeoutAt: Date.now() + 86_400_000
354
+ });
355
+ });
356
+ try {
357
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
358
+ await runInDurableObject(stub, async (instance) => {
359
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
360
+ instance.onStatusChange_experimental = async (status) => {
361
+ resolve(status);
362
+ };
363
+ await instance.create({ definitionVersion: "2026-03-19" });
364
+ await expect(promise).resolves.toBe("running");
365
+
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
+ );
369
+ });
370
+ } finally {
371
+ executeSpy.mockRestore();
372
+ }
373
+ });
374
+
375
+ it("is a no-op when the workflow is already in a terminal state", async () => {
376
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
377
+ await runInDurableObject(stub, async (instance) => {
378
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
379
+ instance.onStatusChange_experimental = async (status) => {
380
+ if (status === "running") return;
381
+ resolve(status);
382
+ };
383
+ await instance.create({ definitionVersion: "2026-03-19" });
384
+ await expect(promise).resolves.toBe("completed");
385
+ expect(instance.getStatus()).toBe("completed");
386
+
387
+ await instance.create({ definitionVersion: "2026-03-20" });
388
+ expect(instance.getStatus()).toBe("completed");
389
+ });
390
+ });
391
+ });
392
+
393
+ describe("nested run steps", () => {
394
+ it("chains parentStepId across three nested run() levels", async () => {
395
+ const executeSpy = vi
396
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
397
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
398
+ await this.run("L0", async () => {
399
+ await this.run("L1", async () => {
400
+ await this.run("L2", async () => "deep");
401
+ });
402
+ });
403
+ });
404
+ try {
405
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
406
+ await runInDurableObject(stub, async (instance) => {
407
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
408
+ instance.onStatusChange_experimental = async (status) => {
409
+ if (status === "running") return;
410
+ resolve(status);
411
+ };
412
+
413
+ await instance.create({ definitionVersion: "2026-03-19" });
414
+ await expect(promise).resolves.toBe("completed");
415
+
416
+ 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);
436
+ }
437
+ });
438
+ } finally {
439
+ executeSpy.mockRestore();
440
+ }
441
+ });
442
+
443
+ it("assigns parentStepId null to a root run() that follows a completed nested run()", async () => {
444
+ const executeSpy = vi
445
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
446
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
447
+ await this.run("nest-outer", async () => {
448
+ await this.run("nest-inner", async () => 1);
449
+ });
450
+ await this.run("root-after", async () => 2);
451
+ });
452
+ try {
453
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
454
+ await runInDurableObject(stub, async (instance) => {
455
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
456
+ instance.onStatusChange_experimental = async (status) => {
457
+ if (status === "running") return;
458
+ resolve(status);
459
+ };
460
+
461
+ await instance.create({ definitionVersion: "2026-03-19" });
462
+ await expect(promise).resolves.toBe("completed");
463
+
464
+ 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
+ });
470
+ expect(steps.find((s) => s.id === "nest-inner")).toMatchObject({
471
+ parentStepId: "nest-outer"
472
+ });
473
+ });
474
+ } finally {
475
+ executeSpy.mockRestore();
476
+ }
477
+ });
478
+
479
+ it("Promise.all: parallel branches each with nested run() complete and keep distinct parent chains", async () => {
480
+ const executeSpy = vi
481
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
482
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
483
+ await Promise.all([
484
+ this.run("branch-a", async () => {
485
+ const v = await this.run("branch-a-inner", async () => "a");
486
+ return v;
487
+ }),
488
+ this.run("branch-b", async () => {
489
+ const v = await this.run("branch-b-inner", async () => "b");
490
+ return v;
491
+ })
492
+ ]);
493
+ });
494
+ try {
495
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
496
+ await runInDurableObject(stub, async (instance) => {
497
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
498
+ instance.onStatusChange_experimental = async (status) => {
499
+ if (status === "running") return;
500
+ resolve(status);
501
+ };
502
+
503
+ await instance.create({ definitionVersion: "2026-03-19" });
504
+ await expect(promise).resolves.toBe("completed");
505
+
506
+ 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);
531
+ });
532
+ } finally {
533
+ executeSpy.mockRestore();
534
+ }
535
+ });
536
+
537
+ it("Promise.allSettled: nested run() in one branch still records parentStepId when the other branch waits", async () => {
538
+ const executeSpy = vi
539
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
540
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
541
+ await Promise.allSettled([
542
+ this.run("nested-branch", async () => {
543
+ await this.run("nested-branch-inner", async () => 99);
544
+ }),
545
+ this.wait("parallel-wait-nested", "evt-nested-parallel", {
546
+ timeoutAt: Date.now() + 86_400_000
547
+ })
548
+ ]);
549
+ });
550
+ try {
551
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
552
+ await runInDurableObject(stub, async (instance) => {
553
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
554
+ instance.onStatusChange_experimental = async (status) => {
555
+ if (status === "running") return;
556
+ resolve(status);
557
+ };
558
+
559
+ await instance.create({ definitionVersion: "2026-03-19" });
560
+ await expect(promise).resolves.toBe("completed");
561
+
562
+ 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
+ });
568
+ expect(steps.find((s) => s.id === "parallel-wait-nested")).toMatchObject({
569
+ type: "wait",
570
+ state: "waiting"
571
+ });
572
+ });
573
+ } finally {
574
+ executeSpy.mockRestore();
575
+ }
576
+ });
577
+
578
+ it("records parentStepId on sleep() nested inside run()", async () => {
579
+ const executeSpy = vi
580
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
581
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
582
+ await this.run("outer-sleep", async () => {
583
+ await this.sleep("deep-sleep", 0);
584
+ });
585
+ });
586
+ try {
587
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
588
+ await runInDurableObject(stub, async (instance) => {
589
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
590
+ instance.onStatusChange_experimental = async (status) => {
591
+ if (status === "running") return;
592
+ resolve(status);
593
+ };
594
+
595
+ await instance.create({ definitionVersion: "2026-03-19" });
596
+ await expect(promise).resolves.toBe("completed");
597
+
598
+ const steps = instance.getSteps_experimental();
599
+ expect(steps.find((s) => s.id === "deep-sleep")).toMatchObject({
600
+ type: "sleep",
601
+ parentStepId: "outer-sleep",
602
+ state: "elapsed"
603
+ });
604
+ });
605
+ } finally {
606
+ executeSpy.mockRestore();
607
+ }
608
+ });
609
+
610
+ it("records parentStepId on wait() nested inside run() and completes after handleInboundEvent", async () => {
611
+ const executeSpy = vi
612
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
613
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
614
+ await this.run("outer-wait", async () => {
615
+ await this.wait("deep-wait", "deep-event", {
616
+ timeoutAt: Date.now() + 86_400_000
617
+ });
618
+ });
619
+ });
620
+ try {
621
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
622
+ await runInDurableObject(stub, async (instance) => {
623
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
624
+ instance.onStatusChange_experimental = async (status) => {
625
+ if (status === "running") return;
626
+ resolve(status);
627
+ };
628
+
629
+ await instance.create({ definitionVersion: "2026-03-19" });
630
+
631
+ await expect
632
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "deep-wait")?.state)
633
+ .toBe("waiting");
634
+
635
+ const stepsWaiting = instance.getSteps_experimental();
636
+ expect(stepsWaiting.find((s) => s.id === "deep-wait")).toMatchObject({
637
+ type: "wait",
638
+ parentStepId: "outer-wait",
639
+ state: "waiting"
640
+ });
641
+
642
+ await instance.handleInboundEvent("deep-event", { ok: true });
643
+ await expect(promise).resolves.toBe("completed");
644
+
645
+ expect(instance.getSteps_experimental().find((s) => s.id === "deep-wait")).toMatchObject({
646
+ type: "wait",
647
+ parentStepId: "outer-wait",
648
+ 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"
654
+ });
655
+ });
656
+ } finally {
657
+ executeSpy.mockRestore();
658
+ }
659
+ });
660
+
661
+ it("fails the workflow when a nested run() throws NonRetryableStepError", async () => {
662
+ const executeSpy = vi
663
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
664
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
665
+ await this.run("fail-outer", async () => {
666
+ await this.run("fail-inner", async () => {
667
+ throw new NonRetryableStepError("inner only");
668
+ });
669
+ });
670
+ });
671
+ try {
672
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
673
+ await runInDurableObject(stub, async (instance) => {
674
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
675
+ instance.onStatusChange_experimental = async (status) => {
676
+ if (status === "running") return;
677
+ resolve(status);
678
+ };
679
+
680
+ await instance.create({ definitionVersion: "2026-03-19" });
681
+ await expect(promise).resolves.toBe("failed");
682
+
683
+ 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
+ });
694
+ });
695
+ } finally {
696
+ executeSpy.mockRestore();
697
+ }
698
+ });
699
+
700
+ describe("nested error propagation", () => {
701
+ it("does not record attempt_failed on the outer run when the inner run suspends for retry, then completes", async () => {
702
+ let innerAttempts = 0;
703
+ const executeSpy = vi
704
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
705
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
706
+ await this.run("suspend-outer", async () => {
707
+ await this.run(
708
+ "suspend-inner",
709
+ async () => {
710
+ innerAttempts += 1;
711
+ if (innerAttempts < 2) {
712
+ throw new Error("retryable inner");
713
+ }
714
+ return "done";
715
+ },
716
+ { maxAttempts: 3 }
717
+ );
718
+ });
719
+ });
720
+ try {
721
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
722
+ await runInDurableObject(stub, async (instance) => {
723
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
724
+ instance.onStatusChange_experimental = async (status) => {
725
+ if (status === "running") return;
726
+ resolve(status);
727
+ };
728
+
729
+ await instance.create({ definitionVersion: "2026-03-19" });
730
+ await expect(promise).resolves.toBe("completed");
731
+
732
+ 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
+ });
743
+ });
744
+ } finally {
745
+ executeSpy.mockRestore();
746
+ }
747
+ });
748
+
749
+ it("fails the workflow when the inner run exhausts maxAttempts", async () => {
750
+ const executeSpy = vi
751
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
752
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
753
+ await this.run("ex-outer", async () => {
754
+ await this.run(
755
+ "ex-inner",
756
+ async () => {
757
+ throw new Error("always fail");
758
+ },
759
+ { maxAttempts: 1 }
760
+ );
761
+ });
762
+ });
763
+ try {
764
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
765
+ await runInDurableObject(stub, async (instance) => {
766
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
767
+ instance.onStatusChange_experimental = async (status) => {
768
+ if (status === "running") return;
769
+ resolve(status);
770
+ };
771
+
772
+ await instance.create({ definitionVersion: "2026-03-19" });
773
+ await expect(promise).resolves.toBe("failed");
774
+
775
+ const steps = instance.getSteps_experimental();
776
+ expect(steps.find((s) => s.id === "ex-inner")).toMatchObject({
777
+ type: "run",
778
+ state: "failed",
779
+ errorName: "Error",
780
+ errorMessage: "Error: always fail"
781
+ });
782
+ expect(steps.find((s) => s.id === "ex-outer")).toMatchObject({
783
+ type: "run",
784
+ state: "failed",
785
+ errorName: "Error",
786
+ errorMessage: "Error"
787
+ });
788
+ });
789
+ } finally {
790
+ executeSpy.mockRestore();
791
+ }
792
+ });
793
+
794
+ it("records the outer run failure when the outer callback throws after the inner run succeeded", async () => {
795
+ const executeSpy = vi
796
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
797
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
798
+ await this.run("post-outer", async () => {
799
+ await this.run("post-inner", async () => 42);
800
+ throw new Error("outer-only failure");
801
+ });
802
+ });
803
+ try {
804
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
805
+ await runInDurableObject(stub, async (instance) => {
806
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
807
+ instance.onStatusChange_experimental = async (status) => {
808
+ if (status === "running") return;
809
+ resolve(status);
810
+ };
811
+
812
+ await instance.create({ definitionVersion: "2026-03-19" });
813
+ await expect(promise).resolves.toBe("failed");
814
+
815
+ 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",
822
+ state: "failed",
823
+ errorMessage: expect.stringContaining("outer-only failure")
824
+ });
825
+ });
826
+ } finally {
827
+ executeSpy.mockRestore();
828
+ }
829
+ });
830
+
831
+ it("does not record attempt_failed on a root run that only suspends via nested wait (child step explains suspend)", async () => {
832
+ const executeSpy = vi
833
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
834
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
835
+ await this.run("root-wait-run", async () => {
836
+ await this.wait("root-deep-wait", "root-deep-ev", {
837
+ timeoutAt: Date.now() + 86_400_000
838
+ });
839
+ });
840
+ });
841
+ try {
842
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
843
+ await runInDurableObject(stub, async (instance) => {
844
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
845
+ instance.onStatusChange_experimental = async (status) => {
846
+ if (status === "running") return;
847
+ resolve(status);
848
+ };
849
+
850
+ await instance.create({ definitionVersion: "2026-03-19" });
851
+ await expect
852
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "root-deep-wait")?.state)
853
+ .toBe("waiting");
854
+
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);
859
+
860
+ await instance.handleInboundEvent("root-deep-ev", true);
861
+ await expect(promise).resolves.toBe("completed");
862
+
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);
867
+ });
868
+ } finally {
869
+ executeSpy.mockRestore();
870
+ }
871
+ });
872
+ });
873
+ });
874
+
875
+ describe("Promise.allSettled", () => {
876
+ it("swallows SuspendWorkflowError so the workflow completes while a wait step is still waiting", async () => {
877
+ const executeSpy = vi
878
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
879
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
880
+ await Promise.allSettled([
881
+ this.run("parallel-run", async () => 1),
882
+ this.wait("parallel-wait", "parallel-event", {
883
+ timeoutAt: Date.now() + 86_400_000
884
+ })
885
+ ]);
886
+ });
887
+ try {
888
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
889
+ await runInDurableObject(stub, async (instance) => {
890
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
891
+ instance.onStatusChange_experimental = async (status) => {
892
+ if (status === "running") return;
893
+ resolve(status);
894
+ };
895
+
896
+ await instance.create({ definitionVersion: "2026-03-19" });
897
+ await expect(promise).resolves.toBe("completed");
898
+
899
+ const steps = instance.getSteps_experimental();
900
+ expect(steps).toHaveLength(2);
901
+ expect(steps.find((s) => s.id === "parallel-run")).toMatchObject({
902
+ type: "run",
903
+ state: "succeeded"
904
+ });
905
+ expect(steps.find((s) => s.id === "parallel-wait")).toMatchObject({
906
+ type: "wait",
907
+ state: "waiting"
908
+ });
909
+ });
910
+ } finally {
911
+ executeSpy.mockRestore();
912
+ }
913
+ });
914
+
915
+ it("swallows NonRetryableStepError so the workflow completes despite a failed run step", async () => {
916
+ const executeSpy = vi
917
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
918
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
919
+ await Promise.allSettled([
920
+ this.run("parallel-fail", async () => {
921
+ throw new NonRetryableStepError("branch failed");
922
+ }),
923
+ this.run("parallel-ok", async () => 1)
924
+ ]);
925
+ });
926
+ try {
927
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
928
+ await runInDurableObject(stub, async (instance) => {
929
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
930
+ instance.onStatusChange_experimental = async (status) => {
931
+ if (status === "running") return;
932
+ resolve(status);
933
+ };
934
+
935
+ await instance.create({ definitionVersion: "2026-03-19" });
936
+ await expect(promise).resolves.toBe("completed");
937
+
938
+ const steps = instance.getSteps_experimental();
939
+ 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
+ });
949
+ });
950
+ } finally {
951
+ executeSpy.mockRestore();
952
+ }
953
+ });
954
+
955
+ it("forwards suspend when the caller rethrows after inspecting settled results", async () => {
956
+ const executeSpy = vi
957
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
958
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
959
+ const results = await Promise.allSettled([
960
+ this.run("allsettled-rerun-run", async () => 1),
961
+ this.wait("allsettled-rerun-wait", "allsettled-rerun-event", {
962
+ timeoutAt: Date.now() + 86_400_000
963
+ })
964
+ ]);
965
+ for (const r of results) {
966
+ if (r.status === "rejected") {
967
+ throw r.reason;
968
+ }
969
+ }
970
+ });
971
+ try {
972
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
973
+ await runInDurableObject(stub, async (instance) => {
974
+ const terminalStatuses: WorkflowStatus[] = [];
975
+ instance.onStatusChange_experimental = async (status) => {
976
+ if (status === "running") return;
977
+ terminalStatuses.push(status);
978
+ };
979
+
980
+ await instance.create({ definitionVersion: "2026-03-19" });
981
+ await expect.poll(() => instance.getStatus()).toBe("running");
982
+ expect(terminalStatuses).toHaveLength(0);
983
+
984
+ await expect
985
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "allsettled-rerun-wait")?.state)
986
+ .toBe("waiting");
987
+
988
+ const steps = instance.getSteps_experimental();
989
+ expect(steps.find((s) => s.id === "allsettled-rerun-wait")).toMatchObject({
990
+ type: "wait",
991
+ state: "waiting"
992
+ });
993
+ // Unlike `Promise.all`, `allSettled` waits for every branch before returning, so the run can finish
994
+ // durably before we rethrow the `wait()` rejection.
995
+ expect(steps.find((s) => s.id === "allsettled-rerun-run")).toMatchObject({
996
+ type: "run",
997
+ state: "succeeded"
998
+ });
999
+ });
1000
+ } finally {
1001
+ executeSpy.mockRestore();
1002
+ }
1003
+ });
1004
+ });
1005
+
1006
+ describe("Promise.all", () => {
1007
+ it("propagates SuspendWorkflowError so the workflow stays running with a waiting step", async () => {
1008
+ const executeSpy = vi
1009
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1010
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1011
+ await Promise.all([
1012
+ this.run("parallel-run", async () => 1),
1013
+ this.wait("parallel-wait", "parallel-event", {
1014
+ timeoutAt: Date.now() + 86_400_000
1015
+ })
1016
+ ]);
1017
+ });
1018
+ try {
1019
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1020
+ await runInDurableObject(stub, async (instance) => {
1021
+ const terminalStatuses: WorkflowStatus[] = [];
1022
+ instance.onStatusChange_experimental = async (status) => {
1023
+ if (status === "running") return;
1024
+ terminalStatuses.push(status);
1025
+ };
1026
+
1027
+ await instance.create({ definitionVersion: "2026-03-19" });
1028
+ await expect.poll(() => instance.getStatus()).toBe("running");
1029
+ expect(terminalStatuses).toHaveLength(0);
1030
+
1031
+ await expect
1032
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "parallel-wait")?.state)
1033
+ .toBe("waiting");
1034
+
1035
+ const steps = instance.getSteps_experimental();
1036
+ expect(steps.find((s) => s.id === "parallel-wait")).toMatchObject({
1037
+ type: "wait",
1038
+ state: "waiting"
1039
+ });
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
+ });
1045
+ });
1046
+ } finally {
1047
+ executeSpy.mockRestore();
1048
+ }
1049
+ });
1050
+ });
1051
+
1052
+ describe("alarm()", () => {
1053
+ it("no alarm is scheduled after the workflow has completed", async () => {
1054
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1055
+ await runInDurableObject(stub, async (instance) => {
1056
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1057
+ instance.onStatusChange_experimental = async (status) => {
1058
+ if (status === "running") return;
1059
+ resolve(status);
1060
+ };
1061
+ await instance.create({ definitionVersion: "2026-03-19" });
1062
+ await expect(promise).resolves.toBe("completed");
1063
+ });
1064
+
1065
+ expect(await runDurableObjectAlarm(stub)).toBe(false);
1066
+ });
1067
+
1068
+ it("no alarm is scheduled after the workflow has failed", async () => {
1069
+ const executeSpy = vi.spyOn(TestWorkflowDefinition.prototype, "execute").mockImplementation(async function () {
1070
+ throw new Error("fatal");
1071
+ });
1072
+ try {
1073
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1074
+ await runInDurableObject(stub, async (instance) => {
1075
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1076
+ instance.onStatusChange_experimental = async (status) => {
1077
+ if (status === "running") return;
1078
+ resolve(status);
1079
+ };
1080
+ await instance.create({ definitionVersion: "2026-03-19" });
1081
+ await expect(promise).resolves.toBe("failed");
1082
+ });
1083
+
1084
+ expect(await runDurableObjectAlarm(stub)).toBe(false);
1085
+ } finally {
1086
+ executeSpy.mockRestore();
1087
+ }
1088
+ });
1089
+
1090
+ it("no alarm is scheduled after the workflow has been cancelled", async () => {
1091
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1092
+ await runInDurableObject(stub, async (instance) => {
1093
+ await instance.cancel("test reason");
1094
+ });
1095
+
1096
+ expect(await runDurableObjectAlarm(stub)).toBe(false);
1097
+ });
1098
+ });
1099
+
1100
+ describe("pause and resume", () => {
1101
+ it("pause() transitions running workflow to paused and clears alarm", async () => {
1102
+ const executeSpy = vi
1103
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1104
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1105
+ await this.wait("wait-1", "never-arrives", {
1106
+ timeoutAt: Date.now() + 86_400_000
1107
+ });
1108
+ });
1109
+
1110
+ try {
1111
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1112
+ await runInDurableObject(stub, async (instance) => {
1113
+ await instance.create({ definitionVersion: "2026-03-19" });
1114
+ await expect.poll(() => instance.getStatus()).toBe("running");
1115
+ await expect
1116
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1117
+ .toBe("waiting");
1118
+
1119
+ await instance.pause();
1120
+ expect(instance.getStatus()).toBe("paused");
1121
+ });
1122
+
1123
+ expect(await runDurableObjectAlarm(stub)).toBe(false);
1124
+ } finally {
1125
+ executeSpy.mockRestore();
1126
+ }
1127
+ });
1128
+
1129
+ it("pause() fires onStatusChange_experimental with 'paused'", async () => {
1130
+ const executeSpy = vi
1131
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1132
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1133
+ await this.sleep("sleep-1", 60_000);
1134
+ });
1135
+
1136
+ try {
1137
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1138
+ await runInDurableObject(stub, async (instance) => {
1139
+ const { resolve, promise } = Promise.withResolvers<void>();
1140
+ instance.onStatusChange_experimental = async (status) => {
1141
+ if (status === "paused") resolve();
1142
+ };
1143
+
1144
+ await instance.create({ definitionVersion: "2026-03-19" });
1145
+ await instance.pause();
1146
+ await promise;
1147
+ });
1148
+ } finally {
1149
+ executeSpy.mockRestore();
1150
+ }
1151
+ });
1152
+
1153
+ it("pause() is a no-op when workflow is pending", async () => {
1154
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1155
+ await runInDurableObject(stub, async (instance) => {
1156
+ expect(instance.getStatus()).toBe("pending");
1157
+ await instance.pause();
1158
+ expect(instance.getStatus()).toBe("pending");
1159
+ });
1160
+ });
1161
+
1162
+ it("pause() is a no-op when workflow is already paused", async () => {
1163
+ const executeSpy = vi
1164
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1165
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1166
+ await this.sleep("sleep-1", 60_000);
1167
+ });
1168
+
1169
+ try {
1170
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1171
+ await runInDurableObject(stub, async (instance) => {
1172
+ await instance.create({ definitionVersion: "2026-03-19" });
1173
+ await expect.poll(() => instance.getStatus()).toBe("running");
1174
+ await instance.pause();
1175
+ expect(instance.getStatus()).toBe("paused");
1176
+
1177
+ await instance.pause();
1178
+ expect(instance.getStatus()).toBe("paused");
1179
+ });
1180
+ } finally {
1181
+ executeSpy.mockRestore();
1182
+ }
1183
+ });
1184
+
1185
+ it("resume() transitions paused workflow back to running", async () => {
1186
+ const executeSpy = vi
1187
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1188
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1189
+ await this.run("step-1", async () => 1);
1190
+ });
1191
+
1192
+ try {
1193
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1194
+ await runInDurableObject(stub, async (instance) => {
1195
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1196
+ instance.onStatusChange_experimental = async (status) => {
1197
+ if (status === "running" || status === "paused") return;
1198
+ resolve(status);
1199
+ };
1200
+
1201
+ await instance.create({ definitionVersion: "2026-03-19" });
1202
+ await expect.poll(() => instance.getStatus()).toBe("running");
1203
+ await instance.pause();
1204
+ expect(instance.getStatus()).toBe("paused");
1205
+
1206
+ await instance.resume();
1207
+ await expect(promise).resolves.toBe("completed");
1208
+ });
1209
+ } finally {
1210
+ executeSpy.mockRestore();
1211
+ }
1212
+ });
1213
+
1214
+ it("resume() throws when workflow is not paused", async () => {
1215
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1216
+ await runInDurableObject(stub, async (instance) => {
1217
+ expect(instance.getStatus()).toBe("pending");
1218
+ await expect(instance.resume()).rejects.toThrow(
1219
+ "Cannot resume workflow: expected status 'paused' but got 'pending'."
1220
+ );
1221
+ });
1222
+ });
1223
+
1224
+ it("resume() throws when workflow is running", async () => {
1225
+ const executeSpy = vi
1226
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1227
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1228
+ await this.sleep("sleep-1", 60_000);
1229
+ });
1230
+
1231
+ try {
1232
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1233
+ await runInDurableObject(stub, async (instance) => {
1234
+ await instance.create({ definitionVersion: "2026-03-19" });
1235
+ await expect.poll(() => instance.getStatus()).toBe("running");
1236
+ await expect(instance.resume()).rejects.toThrow(
1237
+ "Cannot resume workflow: expected status 'paused' but got 'running'."
1238
+ );
1239
+ });
1240
+ } finally {
1241
+ executeSpy.mockRestore();
1242
+ }
1243
+ });
1244
+
1245
+ it("handleInboundEvent() queues event without satisfying wait step when paused", async () => {
1246
+ const executeSpy = vi
1247
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1248
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1249
+ await this.wait("wait-1", "event-1");
1250
+ });
1251
+
1252
+ try {
1253
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1254
+ await runInDurableObject(stub, async (instance) => {
1255
+ await instance.create({ definitionVersion: "2026-03-19" });
1256
+
1257
+ await expect
1258
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1259
+ .toBe("waiting");
1260
+
1261
+ await instance.pause();
1262
+
1263
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-1")).toMatchObject({
1264
+ type: "wait",
1265
+ state: "waiting"
1266
+ });
1267
+
1268
+ await instance.handleInboundEvent("event-1", { data: "test" });
1269
+
1270
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-1")).toMatchObject({
1271
+ type: "wait",
1272
+ state: "waiting"
1273
+ });
1274
+ });
1275
+ } finally {
1276
+ executeSpy.mockRestore();
1277
+ }
1278
+ });
1279
+
1280
+ it("queued event is consumed after resume()", async () => {
1281
+ const executeSpy = vi
1282
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1283
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1284
+ await this.wait("wait-1", "event-1");
1285
+ });
1286
+
1287
+ try {
1288
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1289
+ await runInDurableObject(stub, async (instance) => {
1290
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1291
+ instance.onStatusChange_experimental = async (status) => {
1292
+ if (status === "running" || status === "paused") return;
1293
+ resolve(status);
1294
+ };
1295
+
1296
+ await instance.create({ definitionVersion: "2026-03-19" });
1297
+ await expect
1298
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1299
+ .toBe("waiting");
1300
+ await instance.pause();
1301
+
1302
+ await instance.handleInboundEvent("event-1", { data: "test" });
1303
+
1304
+ await instance.resume();
1305
+ await expect(promise).resolves.toBe("completed");
1306
+
1307
+ expect(instance.getSteps_experimental().find((s) => s.id === "wait-1")).toMatchObject({
1308
+ type: "wait",
1309
+ state: "satisfied",
1310
+ payload: JSON.stringify({ data: "test" })
1311
+ });
1312
+ });
1313
+ } finally {
1314
+ executeSpy.mockRestore();
1315
+ }
1316
+ });
1317
+
1318
+ it("alarm() does not call run() when paused", async () => {
1319
+ const executeSpy = vi
1320
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1321
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1322
+ await this.wait("wait-1", "never-arrives", {
1323
+ timeoutAt: Date.now() + 86_400_000
1324
+ });
1325
+ });
1326
+
1327
+ try {
1328
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1329
+ await runInDurableObject(stub, async (instance) => {
1330
+ await instance.create({ definitionVersion: "2026-03-19" });
1331
+ await expect.poll(() => instance.getStatus()).toBe("running");
1332
+ await expect
1333
+ .poll(() => instance.getSteps_experimental().find((s) => s.id === "wait-1")?.state)
1334
+ .toBe("waiting");
1335
+
1336
+ await instance.pause();
1337
+ expect(instance.getStatus()).toBe("paused");
1338
+ });
1339
+
1340
+ expect(await runDurableObjectAlarm(stub)).toBe(false);
1341
+
1342
+ await runInDurableObject(stub, async (instance) => {
1343
+ expect(instance.getStatus()).toBe("paused");
1344
+ });
1345
+ } finally {
1346
+ executeSpy.mockRestore();
1347
+ }
1348
+ });
1349
+ });
1350
+
1351
+ describe("getWorkflowEvents_experimental()", () => {
1352
+ it("records 'created' when workflow is first initialized", async () => {
1353
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1354
+ await runInDurableObject(stub, async (instance) => {
1355
+ const events = instance.getWorkflowEvents_experimental();
1356
+ expect(events).toHaveLength(1);
1357
+ expect(events[0]).toMatchObject({
1358
+ type: "created",
1359
+ recordedAt: expect.any(Date)
1360
+ });
1361
+ });
1362
+ });
1363
+
1364
+ it("records 'started' when workflow transitions from pending to running", async () => {
1365
+ const executeSpy = vi
1366
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1367
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1368
+ await this.run("step-1", async () => 1);
1369
+ });
1370
+
1371
+ try {
1372
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1373
+ await runInDurableObject(stub, async (instance) => {
1374
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1375
+ instance.onStatusChange_experimental = async (status) => {
1376
+ if (status === "running") return;
1377
+ resolve(status);
1378
+ };
1379
+
1380
+ await instance.create({ definitionVersion: "2026-03-19" });
1381
+ await expect(promise).resolves.toBe("completed");
1382
+
1383
+ const events = instance.getWorkflowEvents_experimental();
1384
+ expect(events.map((event) => event.type)).toEqual(["created", "started", "completed"]);
1385
+ });
1386
+ } finally {
1387
+ executeSpy.mockRestore();
1388
+ }
1389
+ });
1390
+
1391
+ it("records 'paused' when workflow is paused", async () => {
1392
+ const executeSpy = vi
1393
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1394
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1395
+ await this.sleep("sleep-1", 60_000);
1396
+ });
1397
+
1398
+ try {
1399
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1400
+ await runInDurableObject(stub, async (instance) => {
1401
+ await instance.create({ definitionVersion: "2026-03-19" });
1402
+ await expect.poll(() => instance.getStatus()).toBe("running");
1403
+ await instance.pause();
1404
+
1405
+ const events = instance.getWorkflowEvents_experimental();
1406
+ expect(events.map((event) => event.type)).toEqual(["created", "started", "paused"]);
1407
+ });
1408
+ } finally {
1409
+ executeSpy.mockRestore();
1410
+ }
1411
+ });
1412
+
1413
+ it("records 'resumed' when workflow is resumed from paused", async () => {
1414
+ const executeSpy = vi
1415
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1416
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1417
+ await this.run("step-1", async () => 1);
1418
+ });
1419
+
1420
+ try {
1421
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1422
+ await runInDurableObject(stub, async (instance) => {
1423
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1424
+ instance.onStatusChange_experimental = async (status) => {
1425
+ if (status === "running" || status === "paused") return;
1426
+ resolve(status);
1427
+ };
1428
+
1429
+ await instance.create({ definitionVersion: "2026-03-19" });
1430
+ await expect.poll(() => instance.getStatus()).toBe("running");
1431
+ await instance.pause();
1432
+ await instance.resume();
1433
+ await expect(promise).resolves.toBe("completed");
1434
+
1435
+ const events = instance.getWorkflowEvents_experimental();
1436
+ expect(events.map((event) => event.type)).toEqual(["created", "started", "paused", "resumed", "completed"]);
1437
+ });
1438
+ } finally {
1439
+ executeSpy.mockRestore();
1440
+ }
1441
+ });
1442
+
1443
+ it("records 'failed' when workflow fails", async () => {
1444
+ const executeSpy = vi
1445
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1446
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1447
+ await this.run("step-1", async () => {
1448
+ throw new Error("intentional failure");
1449
+ });
1450
+ });
1451
+
1452
+ try {
1453
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1454
+ await runInDurableObject(stub, async (instance) => {
1455
+ const { resolve, promise } = Promise.withResolvers<WorkflowStatus>();
1456
+ instance.onStatusChange_experimental = async (status) => {
1457
+ if (status === "running") return;
1458
+ resolve(status);
1459
+ };
1460
+
1461
+ await instance.create({ definitionVersion: "2026-03-19" });
1462
+ await expect(promise).resolves.toBe("failed");
1463
+
1464
+ const events = instance.getWorkflowEvents_experimental();
1465
+ expect(events.map((event) => event.type)).toEqual(["created", "started", "failed"]);
1466
+ });
1467
+ } finally {
1468
+ executeSpy.mockRestore();
1469
+ }
1470
+ });
1471
+
1472
+ it("records 'cancelled' with reason when workflow is cancelled", async () => {
1473
+ const executeSpy = vi
1474
+ .spyOn(TestWorkflowDefinition.prototype, "execute")
1475
+ .mockImplementation(async function (this: TestWorkflowDefinition) {
1476
+ await this.sleep("sleep-1", 60_000);
1477
+ });
1478
+
1479
+ try {
1480
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1481
+ await runInDurableObject(stub, async (instance) => {
1482
+ await instance.create({ definitionVersion: "2026-03-19" });
1483
+ await expect.poll(() => instance.getStatus()).toBe("running");
1484
+ await instance.cancel("user requested cancellation");
1485
+
1486
+ const events = instance.getWorkflowEvents_experimental();
1487
+ expect(events.map((event) => event.type)).toEqual(["created", "started", "cancelled"]);
1488
+ const cancelledEntry = events.find((event) => event.type === "cancelled");
1489
+ expect(cancelledEntry).toMatchObject({
1490
+ type: "cancelled",
1491
+ cancellationReason: "user requested cancellation"
1492
+ });
1493
+ });
1494
+ } finally {
1495
+ executeSpy.mockRestore();
1496
+ }
1497
+ });
1498
+
1499
+ it("records 'cancelled' without reason when workflow is cancelled from pending", async () => {
1500
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1501
+ await runInDurableObject(stub, async (instance) => {
1502
+ expect(instance.getStatus()).toBe("pending");
1503
+ await instance.cancel();
1504
+
1505
+ const events = instance.getWorkflowEvents_experimental();
1506
+ expect(events.map((event) => event.type)).toEqual(["created", "cancelled"]);
1507
+ const cancelledEntry = events.find((event) => event.type === "cancelled");
1508
+ expect(cancelledEntry).toMatchObject({
1509
+ type: "cancelled",
1510
+ cancellationReason: undefined
1511
+ });
1512
+ });
1513
+ });
1514
+ });
1515
+
1516
+ describe("WorkflowRuntimeContext", () => {
1517
+ describe("run steps", () => {
1518
+ describe("getOrCreateStep()", () => {
1519
+ it("creates a new run step", async () => {
1520
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1521
+ await runInDurableObject(stub, async (_instance, state) => {
1522
+ const context = new WorkflowRuntimeContext(state.storage);
1523
+ const step = await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1524
+ expect(step).toMatchObject({
1525
+ id: "step-1",
1526
+ type: "run",
1527
+ state: "pending",
1528
+ attemptCount: 0
1529
+ });
1530
+ });
1531
+ });
1532
+
1533
+ it("creates a run step once and returns the same durable row on subsequent reads", async () => {
1534
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1535
+ await runInDurableObject(stub, async (_instance, state) => {
1536
+ 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
+ });
1545
+ expect(first).toEqual(second);
1546
+ });
1547
+ });
1548
+
1549
+ it("does not write an 'attempt_started' step event when a run step is already in progress", async () => {
1550
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1551
+ await runInDurableObject(stub, async (instance, state) => {
1552
+ 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([]);
1556
+ });
1557
+ });
1558
+
1559
+ it("persists 'max_attempts' on the run step row", async () => {
1560
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1561
+ await runInDurableObject(stub, async (_instance, state) => {
1562
+ const context = new WorkflowRuntimeContext(state.storage);
1563
+ const step = await context.getOrCreateStep(createRunStepId("step-1"), {
1564
+ type: "run",
1565
+ maxAttempts: 5,
1566
+ parentStepId: null
1567
+ });
1568
+ expect(step).toMatchObject({
1569
+ id: "step-1",
1570
+ type: "run",
1571
+ maxAttempts: 5
1572
+ });
1573
+ });
1574
+ });
1575
+ });
1576
+
1577
+ describe("hasRunningOrWaitingChildSteps()", () => {
1578
+ it("returns false when the run step has no direct child rows", async () => {
1579
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1580
+ await runInDurableObject(stub, async (_instance, state) => {
1581
+ 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);
1585
+ });
1586
+ });
1587
+
1588
+ it("returns true when the only direct child run is still pending", async () => {
1589
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1590
+ await runInDurableObject(stub, async (_instance, state) => {
1591
+ 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",
1596
+ parentStepId: createRunStepId("parent")
1597
+ });
1598
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1599
+ });
1600
+ });
1601
+
1602
+ it("returns true when a direct child run step is running", async () => {
1603
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1604
+ await runInDurableObject(stub, async (_instance, state) => {
1605
+ 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",
1610
+ parentStepId: createRunStepId("parent")
1611
+ });
1612
+ await context.handleRunAttemptEvent(createRunStepId("child"), { type: "running", attemptCount: 1 });
1613
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("parent"))).resolves.toBe(true);
1614
+ });
1615
+ });
1616
+
1617
+ it("returns true when a direct child run exists in a non-failure state (e.g. pending)", async () => {
1618
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1619
+ await runInDurableObject(stub, async (_instance, state) => {
1620
+ 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",
1626
+ parentStepId: createRunStepId("mid")
1627
+ });
1628
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("gp"))).resolves.toBe(true);
1629
+ });
1630
+ });
1631
+
1632
+ it("returns true when a direct child exists (pending or running) and false for a leaf", async () => {
1633
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1634
+ await runInDurableObject(stub, async (_instance, state) => {
1635
+ 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",
1640
+ parentStepId: createRunStepId("mid")
1641
+ });
1642
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("mid"))).resolves.toBe(true);
1643
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("leaf"))).resolves.toBe(false);
1644
+ });
1645
+ });
1646
+
1647
+ it("returns false when the only direct child run has failed", async () => {
1648
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1649
+ await runInDurableObject(stub, async (_instance, state) => {
1650
+ 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
1663
+ });
1664
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("par"))).resolves.toBe(false);
1665
+ });
1666
+ });
1667
+
1668
+ it("returns true when the only direct child is a non-run step (sleep) in waiting", async () => {
1669
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1670
+ await runInDurableObject(stub, async (_instance, state) => {
1671
+ 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,
1678
+ parentStepId: createRunStepId("run-parent")
1679
+ });
1680
+ await expect(context.hasRunningOrWaitingChildSteps(createRunStepId("run-parent"))).resolves.toBe(true);
1681
+ });
1682
+ });
1683
+ });
1684
+
1685
+ describe("handleRunAttemptEvent({ type: 'running' })", () => {
1686
+ it("moves a run step from 'pending' to 'running'", async () => {
1687
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1688
+ await runInDurableObject(stub, async (_instance, state) => {
1689
+ 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
+ ]);
1723
+ });
1724
+ });
1725
+
1726
+ it("throws when the step does not exist", async () => {
1727
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1728
+ await runInDurableObject(stub, async (_instance, state) => {
1729
+ 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/);
1754
+ });
1755
+ });
1756
+
1757
+ it("rejects when 'next_attempt_at' is in the future", async () => {
1758
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1759
+ await runInDurableObject(stub, async (_instance, state) => {
1760
+ 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/);
1793
+ });
1794
+ });
1795
+ });
1796
+
1797
+ describe("handleRunAttemptEvent({ type: 'succeeded' })", () => {
1798
+ it("moves a run step from 'running' to 'succeeded'", async () => {
1799
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1800
+ await runInDurableObject(stub, async (_instance, state) => {
1801
+ const context = new WorkflowRuntimeContext(state.storage);
1802
+ await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1803
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1804
+ type: "running",
1805
+ attemptCount: 1
1806
+ });
1807
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1808
+ type: "succeeded",
1809
+ attemptCount: 1,
1810
+ result: JSON.stringify({ value: 0 })
1811
+ });
1812
+ const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1813
+ type: "run",
1814
+ parentStepId: null
1815
+ });
1816
+ expect(updatedStep).toMatchObject({
1817
+ state: "succeeded",
1818
+ attemptCount: 1,
1819
+ result: JSON.stringify({ value: 0 }),
1820
+ resolvedAt: expect.any(Date)
1821
+ });
1822
+ });
1823
+ });
1824
+
1825
+ it("writes an 'attempt_succeeded' step event when a run step is succeeded", async () => {
1826
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1827
+ await runInDurableObject(stub, async (instance, state) => {
1828
+ const context = new WorkflowRuntimeContext(state.storage);
1829
+ await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1830
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1831
+ type: "running",
1832
+ attemptCount: 1
1833
+ });
1834
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1835
+ type: "succeeded",
1836
+ attemptCount: 1,
1837
+ result: JSON.stringify({ value: 0 })
1838
+ });
1839
+ expect(await instance.getStepEvents_experimental()).toMatchObject([
1840
+ {
1841
+ type: "attempt_started",
1842
+ stepId: "step-1",
1843
+ attemptNumber: 1,
1844
+ recordedAt: expect.any(Date)
1845
+ },
1846
+ {
1847
+ type: "attempt_succeeded",
1848
+ stepId: "step-1",
1849
+ attemptNumber: 1,
1850
+ recordedAt: expect.any(Date)
1851
+ }
1852
+ ]);
1853
+ });
1854
+ });
1855
+
1856
+ it("throws when the attempt number does not match", async () => {
1857
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1858
+ await runInDurableObject(stub, async (_instance, state) => {
1859
+ const context = new WorkflowRuntimeContext(state.storage);
1860
+ await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1861
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1862
+ type: "running",
1863
+ attemptCount: 1
1864
+ });
1865
+ await expect(
1866
+ context.handleRunAttemptEvent(createRunStepId("step-1"), {
1867
+ type: "succeeded",
1868
+ attemptCount: 999,
1869
+ result: JSON.stringify({ value: 0 })
1870
+ })
1871
+ ).rejects.toThrow(/Unexpected attempt count/);
1872
+ });
1873
+ });
1874
+
1875
+ it("throws when the step does not exist", async () => {
1876
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1877
+ await runInDurableObject(stub, async (_instance, state) => {
1878
+ const context = new WorkflowRuntimeContext(state.storage);
1879
+ await expect(
1880
+ context.handleRunAttemptEvent(createRunStepId("nonexistent"), {
1881
+ type: "succeeded",
1882
+ attemptCount: 1,
1883
+ result: JSON.stringify({ value: 0 })
1884
+ })
1885
+ ).rejects.toThrow(/not found/);
1886
+ });
1887
+ });
1888
+
1889
+ it("rejects when the step is still in 'pending' state", async () => {
1890
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1891
+ await runInDurableObject(stub, async (_instance, state) => {
1892
+ const context = new WorkflowRuntimeContext(state.storage);
1893
+ await context.getOrCreateStep(createRunStepId("step-1"), { type: "run", parentStepId: null });
1894
+ await expect(
1895
+ context.handleRunAttemptEvent(createRunStepId("step-1"), {
1896
+ type: "succeeded",
1897
+ attemptCount: 1,
1898
+ result: JSON.stringify({ value: 1 })
1899
+ })
1900
+ ).rejects.toThrow(/Expected 'running' but got pending/);
1901
+ });
1902
+ });
1903
+ });
1904
+
1905
+ describe("handleRunAttemptEvent({ type: 'failed' })", () => {
1906
+ it("moves a run step from 'running' to 'failed'", async () => {
1907
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1908
+ await runInDurableObject(stub, async (_instance, state) => {
1909
+ const context = new WorkflowRuntimeContext(state.storage);
1910
+ await context.getOrCreateStep(createRunStepId("step-1"), {
1911
+ type: "run",
1912
+ maxAttempts: 1,
1913
+ parentStepId: null
1914
+ });
1915
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1916
+ type: "running",
1917
+ attemptCount: 1
1918
+ });
1919
+ await context.handleRunAttemptEvent(createRunStepId("step-1"), {
1920
+ type: "failed",
1921
+ attemptCount: 1,
1922
+ errorMessage: "error"
1923
+ });
1924
+ const updatedStep = await context.getOrCreateStep(createRunStepId("step-1"), {
1925
+ type: "run",
1926
+ maxAttempts: 1,
1927
+ parentStepId: null
1928
+ });
1929
+ expect(updatedStep).toMatchObject({
1930
+ state: "failed",
1931
+ attemptCount: 1,
1932
+ errorMessage: "error"
1933
+ });
1934
+ });
1935
+ });
1936
+
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 () => {
1974
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
1975
+ await runInDurableObject(stub, async (_instance, state) => {
1976
+ 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());
1997
+ });
1998
+ });
1999
+
2000
+ it("moves a run step to 'failed' when 'isNonRetryableStepError' is true and retries are available", async () => {
2001
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2002
+ await runInDurableObject(stub, async (_instance, state) => {
2003
+ 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",
2017
+ isNonRetryableStepError: true
2018
+ });
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/);
2119
+ });
2120
+ });
2121
+
2122
+ it("throws when the step does not exist", async () => {
2123
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2124
+ await runInDurableObject(stub, async (_instance, state) => {
2125
+ 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);
2163
+ });
2164
+ });
2165
+
2166
+ it("rejects when the step is still in 'pending' state", async () => {
2167
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2168
+ await runInDurableObject(stub, async (_instance, state) => {
2169
+ 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/);
2178
+ });
2179
+ });
2180
+ });
2181
+ });
2182
+
2183
+ describe("sleep steps", () => {
2184
+ describe("getOrCreateStep()", () => {
2185
+ it("creates a sleep step", async () => {
2186
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2187
+ await runInDurableObject(stub, async (_instance, state) => {
2188
+ const context = new WorkflowRuntimeContext(state.storage);
2189
+ const wakeAt = new Date(Date.now() + 60_000);
2190
+ const step = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2191
+ type: "sleep",
2192
+ wakeAt: wakeAt,
2193
+ parentStepId: null
2194
+ });
2195
+ expect(step).toMatchObject({
2196
+ id: "sleep-1",
2197
+ type: "sleep",
2198
+ state: "waiting",
2199
+ wakeAt: wakeAt
2200
+ });
2201
+ });
2202
+ });
2203
+
2204
+ it("creates a sleep step once and returns the same durable row on subsequent reads", async () => {
2205
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2206
+ await runInDurableObject(stub, async (_instance, state) => {
2207
+ 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",
2215
+ wakeAt: new Date(),
2216
+ parentStepId: null
2217
+ });
2218
+ expect(first).toEqual(second);
2219
+ });
2220
+ });
2221
+
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 () => {
2244
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2245
+ await runInDurableObject(stub, async (_instance, state) => {
2246
+ const context = new WorkflowRuntimeContext(state.storage);
2247
+ 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());
2254
+ });
2255
+ });
2256
+
2257
+ it("replaces any existing alarm with a new alarm", async () => {
2258
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2259
+ await runInDurableObject(stub, async (_instance, state) => {
2260
+ await state.storage.setAlarm(Date.now() + 999_999);
2261
+
2262
+ 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,
2267
+ parentStepId: null
2268
+ });
2269
+ expect(await state.storage.getAlarm()).toBe(wakeAt.getTime());
2270
+ });
2271
+ });
2272
+
2273
+ it("does not schedule an alarm when an existing sleep step has a past wake_at", async () => {
2274
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2275
+ await runInDurableObject(stub, async (_instance, state) => {
2276
+ const context = new WorkflowRuntimeContext(state.storage);
2277
+ const pastWakeAt = new Date(Date.now() - 10_000);
2278
+ await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2279
+ type: "sleep",
2280
+ wakeAt: pastWakeAt,
2281
+ parentStepId: null
2282
+ });
2283
+ await state.storage.deleteAlarm();
2284
+
2285
+ await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2286
+ type: "sleep",
2287
+ wakeAt: pastWakeAt,
2288
+ parentStepId: null
2289
+ });
2290
+ expect(await state.storage.getAlarm()).toBeNull();
2291
+ });
2292
+ });
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
+ });
2336
+
2337
+ describe("handleSleepStepEvent({ type: 'elapsed' })", () => {
2338
+ it("moves a sleep step from 'waiting' to 'elapsed'", async () => {
2339
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2340
+ await runInDurableObject(stub, async (_instance, state) => {
2341
+ const context = new WorkflowRuntimeContext(state.storage);
2342
+ await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2343
+ type: "sleep",
2344
+ wakeAt: new Date(),
2345
+ parentStepId: null
2346
+ });
2347
+ context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2348
+ const updatedStep = await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2349
+ type: "sleep",
2350
+ wakeAt: new Date(),
2351
+ parentStepId: null
2352
+ });
2353
+ expect(updatedStep).toMatchObject({
2354
+ state: "elapsed",
2355
+ resolvedAt: expect.any(Date)
2356
+ });
2357
+ });
2358
+ });
2359
+
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
+ it("throws when the step does not exist", async () => {
2388
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2389
+ await runInDurableObject(stub, async (_instance, state) => {
2390
+ const context = new WorkflowRuntimeContext(state.storage);
2391
+ expect(() => context.handleSleepStepEvent(createSleepStepId("nonexistent"), { type: "elapsed" })).toThrow(
2392
+ /not found/
2393
+ );
2394
+ });
2395
+ });
2396
+
2397
+ it("throws when the step is already elapsed", async () => {
2398
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2399
+ await runInDurableObject(stub, async (_instance, state) => {
2400
+ const context = new WorkflowRuntimeContext(state.storage);
2401
+ await context.getOrCreateStep(createSleepStepId("sleep-1"), {
2402
+ type: "sleep",
2403
+ wakeAt: new Date(),
2404
+ parentStepId: null
2405
+ });
2406
+ context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" });
2407
+ expect(() => context.handleSleepStepEvent(createSleepStepId("sleep-1"), { type: "elapsed" })).toThrow(
2408
+ /Expected 'waiting' but got elapsed/
2409
+ );
2410
+ });
2411
+ });
2412
+ });
2413
+ });
2414
+
2415
+ describe("wait steps", () => {
2416
+ describe("getOrCreateStep()", () => {
2417
+ it("creates a wait step when no timeout is provided", async () => {
2418
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2419
+ await runInDurableObject(stub, async (_instance, state) => {
2420
+ const context = new WorkflowRuntimeContext(state.storage);
2421
+ const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2422
+ type: "wait",
2423
+ eventName: "event-1",
2424
+ parentStepId: null
2425
+ });
2426
+ expect(step).toMatchObject({
2427
+ id: "wait-1",
2428
+ type: "wait",
2429
+ state: "waiting",
2430
+ eventName: "event-1"
2431
+ });
2432
+ });
2433
+ });
2434
+
2435
+ it("creates a wait step when a timeout is provided", async () => {
2436
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2437
+ await runInDurableObject(stub, async (_instance, state) => {
2438
+ const context = new WorkflowRuntimeContext(state.storage);
2439
+ const timeoutAt = new Date(Date.now() + 60_000);
2440
+ const step = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2441
+ type: "wait",
2442
+ eventName: "event-1",
2443
+ parentStepId: null,
2444
+ timeoutAt: timeoutAt
2445
+ });
2446
+ expect(step).toMatchObject({
2447
+ id: "wait-1",
2448
+ type: "wait",
2449
+ state: "waiting",
2450
+ eventName: "event-1",
2451
+ timeoutAt: timeoutAt
2452
+ });
2453
+ });
2454
+ });
2455
+
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
+ it("creates a wait step once and returns the same durable row on subsequent reads", async () => {
2480
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2481
+ await runInDurableObject(stub, async (_instance, state) => {
2482
+ const context = new WorkflowRuntimeContext(state.storage);
2483
+ const first = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2484
+ type: "wait",
2485
+ eventName: "event-1",
2486
+ parentStepId: null,
2487
+ timeoutAt: new Date(Date.now() + 60_000)
2488
+ });
2489
+ const second = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2490
+ type: "wait",
2491
+ eventName: "event-1",
2492
+ parentStepId: null,
2493
+ timeoutAt: new Date(Date.now() + 60_000)
2494
+ });
2495
+ expect(first).toEqual(second);
2496
+ });
2497
+ });
2498
+
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 () => {
2515
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2516
+ await runInDurableObject(stub, async (_instance, state) => {
2517
+ await state.storage.setAlarm(Date.now() + 999_999);
2518
+
2519
+ 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",
2523
+ eventName: "event-1",
2524
+ 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
2539
+ });
2540
+ expect(await state.storage.getAlarm()).toBeNull();
2541
+ });
2542
+ });
2543
+
2544
+ it("uses the stored timeout_at for the alarm, not the caller-provided timeoutAt", async () => {
2545
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2546
+ await runInDurableObject(stub, async (_instance, state) => {
2547
+ 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",
2551
+ eventName: "event-1",
2552
+ 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
2563
+ });
2564
+ expect(await state.storage.getAlarm()).toBe(originalTimeout.getTime());
2565
+ });
2566
+ });
2567
+
2568
+ it("does not schedule an alarm when an existing wait step has a past timeout_at", async () => {
2569
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2570
+ await runInDurableObject(stub, async (_instance, state) => {
2571
+ 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",
2650
+ eventName: "event-1",
2651
+ parentStepId: null
2652
+ });
2653
+ expect(step).toMatchObject({
2654
+ 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")
2685
+ });
2686
+ });
2687
+ });
2688
+
2689
+ it("satisfies the earliest matching wait step when multiple are waiting", async () => {
2690
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2691
+ await runInDurableObject(stub, async (instance, state) => {
2692
+ 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",
2706
+ eventName: "event-1",
2707
+ parentStepId: null
2708
+ });
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" });
2719
+ });
2720
+ });
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
+ });
2752
+
2753
+ describe("handleWaitStepEvent({ type: 'timed_out' })", () => {
2754
+ it("moves a wait step from 'waiting' to 'timed_out'", async () => {
2755
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2756
+ await runInDurableObject(stub, async (_instance, state) => {
2757
+ const context = new WorkflowRuntimeContext(state.storage);
2758
+ const timeoutAt = new Date(Date.now() - 1000);
2759
+ await context.getOrCreateStep(createWaitStepId("wait-1"), {
2760
+ type: "wait",
2761
+ eventName: "event-1",
2762
+ parentStepId: null,
2763
+ timeoutAt: timeoutAt
2764
+ });
2765
+ context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2766
+ const updatedStep = await context.getOrCreateStep(createWaitStepId("wait-1"), {
2767
+ type: "wait",
2768
+ 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
2789
+ });
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
+ ]);
2805
+ });
2806
+ });
2807
+
2808
+ it("throws when the step does not exist", async () => {
2809
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2810
+ await runInDurableObject(stub, async (_instance, state) => {
2811
+ const context = new WorkflowRuntimeContext(state.storage);
2812
+ expect(() => context.handleWaitStepEvent(createWaitStepId("nonexistent"), { type: "timed_out" })).toThrow(
2813
+ /not found/
2814
+ );
2815
+ });
2816
+ });
2817
+
2818
+ it("throws when the step is already timed out", async () => {
2819
+ const stub = env.TEST_WORKFLOW_RUNTIME.getByName(crypto.randomUUID());
2820
+ await runInDurableObject(stub, async (_instance, state) => {
2821
+ const context = new WorkflowRuntimeContext(state.storage);
2822
+ await context.getOrCreateStep(createWaitStepId("wait-1"), {
2823
+ type: "wait",
2824
+ eventName: "event-1",
2825
+ parentStepId: null,
2826
+ timeoutAt: new Date(Date.now() - 1000)
2827
+ });
2828
+ context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" });
2829
+ expect(() => context.handleWaitStepEvent(createWaitStepId("wait-1"), { type: "timed_out" })).toThrow(
2830
+ /Expected 'waiting' but got timed_out/
2831
+ );
2832
+ });
2833
+ });
2834
+ });
2835
+ });
2836
+ });
2837
+ });