@multiplier-labs/stepflow 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,1421 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { WorkflowEngine } from "./engine";
3
+ import { MemoryStorageAdapter } from "../storage/memory";
4
+ import { MemoryEventTransport } from "../events/memory";
5
+ import { SilentLogger } from "../utils/logger";
6
+ import type { WorkflowDefinition, WorkflowEvent } from "../index";
7
+
8
+ describe("WorkflowEngine", () => {
9
+ let engine: WorkflowEngine;
10
+ let storage: MemoryStorageAdapter;
11
+ let events: MemoryEventTransport;
12
+
13
+ beforeEach(() => {
14
+ storage = new MemoryStorageAdapter();
15
+ events = new MemoryEventTransport();
16
+ engine = new WorkflowEngine({
17
+ storage,
18
+ events,
19
+ logger: new SilentLogger(),
20
+ });
21
+ });
22
+
23
+ describe("registerWorkflow", () => {
24
+ it("should register a workflow definition", () => {
25
+ const workflow: WorkflowDefinition = {
26
+ kind: "test.workflow",
27
+ name: "Test Workflow",
28
+ steps: [],
29
+ };
30
+
31
+ engine.registerWorkflow(workflow);
32
+
33
+ expect(engine.getWorkflow("test.workflow")).toBeDefined();
34
+ expect(engine.getRegisteredWorkflows()).toContain("test.workflow");
35
+ });
36
+
37
+ it("should throw if workflow is already registered", () => {
38
+ const workflow: WorkflowDefinition = {
39
+ kind: "test.workflow",
40
+ name: "Test Workflow",
41
+ steps: [],
42
+ };
43
+
44
+ engine.registerWorkflow(workflow);
45
+
46
+ expect(() => engine.registerWorkflow(workflow)).toThrow(
47
+ 'Workflow "test.workflow" is already registered',
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("unregisterWorkflow", () => {
53
+ it("should unregister a workflow", () => {
54
+ const workflow: WorkflowDefinition = {
55
+ kind: "test.workflow",
56
+ name: "Test Workflow",
57
+ steps: [],
58
+ };
59
+
60
+ engine.registerWorkflow(workflow);
61
+ expect(engine.unregisterWorkflow("test.workflow")).toBe(true);
62
+ expect(engine.getWorkflow("test.workflow")).toBeUndefined();
63
+ });
64
+
65
+ it("should return false if workflow not found", () => {
66
+ expect(engine.unregisterWorkflow("nonexistent")).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe("startRun", () => {
71
+ it("should throw if workflow is not registered", async () => {
72
+ await expect(engine.startRun({ kind: "nonexistent" })).rejects.toThrow(
73
+ 'Workflow "nonexistent" is not registered',
74
+ );
75
+ });
76
+
77
+ it("should start a workflow run and return run ID", async () => {
78
+ engine.registerWorkflow({
79
+ kind: "test.workflow",
80
+ name: "Test Workflow",
81
+ steps: [
82
+ {
83
+ key: "step1",
84
+ name: "Step 1",
85
+ handler: async () => "result1",
86
+ },
87
+ ],
88
+ });
89
+
90
+ const runId = await engine.startRun({ kind: "test.workflow" });
91
+
92
+ expect(runId).toBeDefined();
93
+ expect(typeof runId).toBe("string");
94
+ });
95
+
96
+ it("should execute steps in order", async () => {
97
+ const executionOrder: string[] = [];
98
+
99
+ engine.registerWorkflow({
100
+ kind: "test.order",
101
+ name: "Test Order",
102
+ steps: [
103
+ {
104
+ key: "first",
105
+ name: "First",
106
+ handler: async () => {
107
+ executionOrder.push("first");
108
+ return "first";
109
+ },
110
+ },
111
+ {
112
+ key: "second",
113
+ name: "Second",
114
+ handler: async () => {
115
+ executionOrder.push("second");
116
+ return "second";
117
+ },
118
+ },
119
+ {
120
+ key: "third",
121
+ name: "Third",
122
+ handler: async () => {
123
+ executionOrder.push("third");
124
+ return "third";
125
+ },
126
+ },
127
+ ],
128
+ });
129
+
130
+ const runId = await engine.startRun({ kind: "test.order" });
131
+ const run = await engine.waitForRun(runId);
132
+
133
+ expect(run.status).toBe("succeeded");
134
+ expect(executionOrder).toEqual(["first", "second", "third"]);
135
+ });
136
+
137
+ it("should pass accumulated results to steps via context", async () => {
138
+ let step2Input: unknown;
139
+
140
+ engine.registerWorkflow({
141
+ kind: "test.results",
142
+ name: "Test Results",
143
+ steps: [
144
+ {
145
+ key: "step1",
146
+ name: "Step 1",
147
+ handler: async () => ({ value: 42 }),
148
+ },
149
+ {
150
+ key: "step2",
151
+ name: "Step 2",
152
+ handler: async (ctx) => {
153
+ step2Input = ctx.results.step1;
154
+ return {
155
+ doubled: (ctx.results.step1 as { value: number }).value * 2,
156
+ };
157
+ },
158
+ },
159
+ ],
160
+ });
161
+
162
+ const runId = await engine.startRun({ kind: "test.results" });
163
+ const run = await engine.waitForRun(runId);
164
+
165
+ expect(run.status).toBe("succeeded");
166
+ expect(step2Input).toEqual({ value: 42 });
167
+ expect(run.context).toEqual({
168
+ step1: { value: 42 },
169
+ step2: { doubled: 84 },
170
+ });
171
+ });
172
+
173
+ it("should pass input to context", async () => {
174
+ let receivedInput: unknown;
175
+
176
+ engine.registerWorkflow({
177
+ kind: "test.input",
178
+ name: "Test Input",
179
+ steps: [
180
+ {
181
+ key: "step1",
182
+ name: "Step 1",
183
+ handler: async (ctx) => {
184
+ receivedInput = ctx.input;
185
+ return "done";
186
+ },
187
+ },
188
+ ],
189
+ });
190
+
191
+ const runId = await engine.startRun({
192
+ kind: "test.input",
193
+ input: { foo: "bar", num: 123 },
194
+ });
195
+ await engine.waitForRun(runId);
196
+
197
+ expect(receivedInput).toEqual({ foo: "bar", num: 123 });
198
+ });
199
+
200
+ it("should pass metadata to context", async () => {
201
+ let receivedMetadata: unknown;
202
+
203
+ engine.registerWorkflow({
204
+ kind: "test.metadata",
205
+ name: "Test Metadata",
206
+ steps: [
207
+ {
208
+ key: "step1",
209
+ name: "Step 1",
210
+ handler: async (ctx) => {
211
+ receivedMetadata = ctx.metadata;
212
+ return "done";
213
+ },
214
+ },
215
+ ],
216
+ });
217
+
218
+ const runId = await engine.startRun({
219
+ kind: "test.metadata",
220
+ metadata: { userId: "user123", topicId: "topic456" },
221
+ });
222
+ await engine.waitForRun(runId);
223
+
224
+ expect(receivedMetadata).toEqual({
225
+ userId: "user123",
226
+ topicId: "topic456",
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("error handling", () => {
232
+ it("should fail the run when a step throws (default strategy)", async () => {
233
+ engine.registerWorkflow({
234
+ kind: "test.fail",
235
+ name: "Test Fail",
236
+ steps: [
237
+ {
238
+ key: "step1",
239
+ name: "Step 1",
240
+ handler: async () => {
241
+ throw new Error("Step failed!");
242
+ },
243
+ },
244
+ ],
245
+ });
246
+
247
+ const runId = await engine.startRun({ kind: "test.fail" });
248
+ const run = await engine.waitForRun(runId);
249
+
250
+ expect(run.status).toBe("failed");
251
+ expect(run.error?.message).toContain("Step failed!");
252
+ });
253
+
254
+ it('should skip step when onError is "skip"', async () => {
255
+ const executionOrder: string[] = [];
256
+
257
+ engine.registerWorkflow({
258
+ kind: "test.skip",
259
+ name: "Test Skip",
260
+ steps: [
261
+ {
262
+ key: "step1",
263
+ name: "Step 1",
264
+ handler: async () => {
265
+ executionOrder.push("step1");
266
+ throw new Error("This will be skipped");
267
+ },
268
+ onError: "skip",
269
+ },
270
+ {
271
+ key: "step2",
272
+ name: "Step 2",
273
+ handler: async () => {
274
+ executionOrder.push("step2");
275
+ return "step2";
276
+ },
277
+ },
278
+ ],
279
+ });
280
+
281
+ const runId = await engine.startRun({ kind: "test.skip" });
282
+ const run = await engine.waitForRun(runId);
283
+
284
+ expect(run.status).toBe("succeeded");
285
+ expect(executionOrder).toEqual(["step1", "step2"]);
286
+ });
287
+
288
+ it('should retry step when onError is "retry"', async () => {
289
+ let attempts = 0;
290
+
291
+ engine.registerWorkflow({
292
+ kind: "test.retry",
293
+ name: "Test Retry",
294
+ steps: [
295
+ {
296
+ key: "step1",
297
+ name: "Step 1",
298
+ handler: async () => {
299
+ attempts++;
300
+ if (attempts < 3) {
301
+ throw new Error(`Attempt ${attempts} failed`);
302
+ }
303
+ return "success";
304
+ },
305
+ onError: "retry",
306
+ maxRetries: 5,
307
+ retryDelay: 10,
308
+ },
309
+ ],
310
+ });
311
+
312
+ const runId = await engine.startRun({ kind: "test.retry" });
313
+ const run = await engine.waitForRun(runId);
314
+
315
+ expect(run.status).toBe("succeeded");
316
+ expect(attempts).toBe(3);
317
+ });
318
+
319
+ it("should fail after exhausting retries", async () => {
320
+ let attempts = 0;
321
+
322
+ engine.registerWorkflow({
323
+ kind: "test.retry.exhaust",
324
+ name: "Test Retry Exhaust",
325
+ steps: [
326
+ {
327
+ key: "step1",
328
+ name: "Step 1",
329
+ handler: async () => {
330
+ attempts++;
331
+ throw new Error(`Attempt ${attempts} failed`);
332
+ },
333
+ onError: "retry",
334
+ maxRetries: 2,
335
+ retryDelay: 10,
336
+ },
337
+ ],
338
+ });
339
+
340
+ const runId = await engine.startRun({ kind: "test.retry.exhaust" });
341
+ const run = await engine.waitForRun(runId);
342
+
343
+ expect(run.status).toBe("failed");
344
+ expect(attempts).toBe(3); // 1 initial + 2 retries
345
+ });
346
+ });
347
+
348
+ describe("skipIf condition", () => {
349
+ it("should skip step when skipIf returns true", async () => {
350
+ const executionOrder: string[] = [];
351
+
352
+ engine.registerWorkflow({
353
+ kind: "test.skipif",
354
+ name: "Test SkipIf",
355
+ steps: [
356
+ {
357
+ key: "step1",
358
+ name: "Step 1",
359
+ handler: async () => {
360
+ executionOrder.push("step1");
361
+ return { skip: true };
362
+ },
363
+ },
364
+ {
365
+ key: "step2",
366
+ name: "Step 2",
367
+ handler: async () => {
368
+ executionOrder.push("step2");
369
+ return "step2";
370
+ },
371
+ skipIf: async (ctx) =>
372
+ (ctx.results.step1 as { skip: boolean }).skip,
373
+ },
374
+ {
375
+ key: "step3",
376
+ name: "Step 3",
377
+ handler: async () => {
378
+ executionOrder.push("step3");
379
+ return "step3";
380
+ },
381
+ },
382
+ ],
383
+ });
384
+
385
+ const runId = await engine.startRun({ kind: "test.skipif" });
386
+ const run = await engine.waitForRun(runId);
387
+
388
+ expect(run.status).toBe("succeeded");
389
+ expect(executionOrder).toEqual(["step1", "step3"]);
390
+ });
391
+ });
392
+
393
+ describe("events", () => {
394
+ it("should emit events during workflow execution", async () => {
395
+ const receivedEvents: WorkflowEvent[] = [];
396
+
397
+ engine.registerWorkflow({
398
+ kind: "test.events",
399
+ name: "Test Events",
400
+ steps: [
401
+ {
402
+ key: "step1",
403
+ name: "Step 1",
404
+ handler: async () => "result",
405
+ },
406
+ ],
407
+ });
408
+
409
+ const unsubscribe = engine.subscribeToAll((event) => {
410
+ receivedEvents.push(event);
411
+ });
412
+
413
+ const runId = await engine.startRun({ kind: "test.events" });
414
+ await engine.waitForRun(runId);
415
+ unsubscribe();
416
+
417
+ const eventTypes = receivedEvents.map((e) => e.eventType);
418
+ expect(eventTypes).toContain("run.created");
419
+ expect(eventTypes).toContain("run.started");
420
+ expect(eventTypes).toContain("step.started");
421
+ expect(eventTypes).toContain("step.completed");
422
+ expect(eventTypes).toContain("run.completed");
423
+ });
424
+
425
+ it("should emit custom events via context.emit", async () => {
426
+ const receivedEvents: WorkflowEvent[] = [];
427
+
428
+ engine.registerWorkflow({
429
+ kind: "test.custom.events",
430
+ name: "Test Custom Events",
431
+ steps: [
432
+ {
433
+ key: "step1",
434
+ name: "Step 1",
435
+ handler: async (ctx) => {
436
+ ctx.emit("custom.progress", { percent: 50 });
437
+ ctx.emit("custom.progress", { percent: 100 });
438
+ return "done";
439
+ },
440
+ },
441
+ ],
442
+ });
443
+
444
+ const unsubscribe = engine.subscribeToAll((event) => {
445
+ receivedEvents.push(event);
446
+ });
447
+
448
+ const runId = await engine.startRun({ kind: "test.custom.events" });
449
+ await engine.waitForRun(runId);
450
+ unsubscribe();
451
+
452
+ const customEvents = receivedEvents.filter(
453
+ (e) => e.eventType === "custom.progress",
454
+ );
455
+ expect(customEvents).toHaveLength(2);
456
+ expect(customEvents[0].payload).toEqual({ percent: 50 });
457
+ expect(customEvents[1].payload).toEqual({ percent: 100 });
458
+ });
459
+ });
460
+
461
+ describe("cancelRun", () => {
462
+ it("should cancel a running workflow", async () => {
463
+ engine.registerWorkflow({
464
+ kind: "test.cancel",
465
+ name: "Test Cancel",
466
+ steps: [
467
+ {
468
+ key: "step1",
469
+ name: "Step 1",
470
+ handler: async () => {
471
+ // Simulate long-running step
472
+ await new Promise((resolve) => setTimeout(resolve, 1000));
473
+ return "done";
474
+ },
475
+ },
476
+ ],
477
+ });
478
+
479
+ const runId = await engine.startRun({ kind: "test.cancel" });
480
+
481
+ // Cancel after a short delay
482
+ await new Promise((resolve) => setTimeout(resolve, 50));
483
+ await engine.cancelRun(runId);
484
+
485
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
486
+ expect(run.status).toBe("canceled");
487
+ });
488
+ });
489
+
490
+ describe("hooks", () => {
491
+ it("should call beforeRun and afterRun hooks", async () => {
492
+ const hookCalls: string[] = [];
493
+
494
+ engine.registerWorkflow({
495
+ kind: "test.hooks",
496
+ name: "Test Hooks",
497
+ steps: [
498
+ {
499
+ key: "step1",
500
+ name: "Step 1",
501
+ handler: async () => {
502
+ hookCalls.push("step1");
503
+ return "done";
504
+ },
505
+ },
506
+ ],
507
+ hooks: {
508
+ beforeRun: async () => {
509
+ hookCalls.push("beforeRun");
510
+ },
511
+ afterRun: async (_, result) => {
512
+ hookCalls.push(`afterRun:${result.status}`);
513
+ },
514
+ },
515
+ });
516
+
517
+ const runId = await engine.startRun({ kind: "test.hooks" });
518
+ await engine.waitForRun(runId);
519
+
520
+ expect(hookCalls).toEqual(["beforeRun", "step1", "afterRun:succeeded"]);
521
+ });
522
+
523
+ it("should call beforeStep and afterStep hooks", async () => {
524
+ const hookCalls: string[] = [];
525
+
526
+ engine.registerWorkflow({
527
+ kind: "test.step.hooks",
528
+ name: "Test Step Hooks",
529
+ steps: [
530
+ {
531
+ key: "step1",
532
+ name: "Step 1",
533
+ handler: async () => "result1",
534
+ },
535
+ {
536
+ key: "step2",
537
+ name: "Step 2",
538
+ handler: async () => "result2",
539
+ },
540
+ ],
541
+ hooks: {
542
+ beforeStep: async (_, step) => {
543
+ hookCalls.push(`before:${step.key}`);
544
+ },
545
+ afterStep: async (_, step) => {
546
+ hookCalls.push(`after:${step.key}`);
547
+ },
548
+ },
549
+ });
550
+
551
+ const runId = await engine.startRun({ kind: "test.step.hooks" });
552
+ await engine.waitForRun(runId);
553
+
554
+ expect(hookCalls).toEqual([
555
+ "before:step1",
556
+ "after:step1",
557
+ "before:step2",
558
+ "after:step2",
559
+ ]);
560
+ });
561
+ });
562
+
563
+ describe("storage", () => {
564
+ it("should persist run records", async () => {
565
+ engine.registerWorkflow({
566
+ kind: "test.storage",
567
+ name: "Test Storage",
568
+ steps: [
569
+ {
570
+ key: "step1",
571
+ name: "Step 1",
572
+ handler: async () => "result",
573
+ },
574
+ ],
575
+ });
576
+
577
+ const runId = await engine.startRun({
578
+ kind: "test.storage",
579
+ input: { foo: "bar" },
580
+ metadata: { userId: "user1" },
581
+ });
582
+ await engine.waitForRun(runId);
583
+
584
+ const run = await storage.getRun(runId);
585
+ expect(run).toBeDefined();
586
+ expect(run?.kind).toBe("test.storage");
587
+ expect(run?.status).toBe("succeeded");
588
+ expect(run?.input).toEqual({ foo: "bar" });
589
+ expect(run?.metadata).toEqual({ userId: "user1" });
590
+ });
591
+
592
+ it("should persist step records", async () => {
593
+ engine.registerWorkflow({
594
+ kind: "test.step.storage",
595
+ name: "Test Step Storage",
596
+ steps: [
597
+ {
598
+ key: "step1",
599
+ name: "Step 1",
600
+ handler: async () => "result1",
601
+ },
602
+ {
603
+ key: "step2",
604
+ name: "Step 2",
605
+ handler: async () => "result2",
606
+ },
607
+ ],
608
+ });
609
+
610
+ const runId = await engine.startRun({ kind: "test.step.storage" });
611
+ await engine.waitForRun(runId);
612
+
613
+ const steps = await storage.getStepsForRun(runId);
614
+ expect(steps).toHaveLength(2);
615
+ expect(steps[0].stepKey).toBe("step1");
616
+ expect(steps[0].status).toBe("succeeded");
617
+ expect(steps[1].stepKey).toBe("step2");
618
+ expect(steps[1].status).toBe("succeeded");
619
+ });
620
+ });
621
+
622
+ describe("workflow timeout", () => {
623
+ it("should timeout a long-running workflow", async () => {
624
+ engine.registerWorkflow({
625
+ kind: "test.timeout",
626
+ name: "Test Timeout",
627
+ timeout: 100, // 100ms timeout
628
+ steps: [
629
+ {
630
+ key: "slow_step",
631
+ name: "Slow Step",
632
+ handler: async () => {
633
+ await new Promise((resolve) => setTimeout(resolve, 500));
634
+ return "done";
635
+ },
636
+ },
637
+ ],
638
+ });
639
+
640
+ const runId = await engine.startRun({ kind: "test.timeout" });
641
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
642
+
643
+ expect(run.status).toBe("failed");
644
+ expect(run.error?.code).toBe("WORKFLOW_TIMEOUT");
645
+ });
646
+
647
+ it("should complete before timeout if fast enough", async () => {
648
+ engine.registerWorkflow({
649
+ kind: "test.fast",
650
+ name: "Test Fast",
651
+ timeout: 1000, // 1s timeout
652
+ steps: [
653
+ {
654
+ key: "fast_step",
655
+ name: "Fast Step",
656
+ handler: async () => {
657
+ await new Promise((resolve) => setTimeout(resolve, 10));
658
+ return "done";
659
+ },
660
+ },
661
+ ],
662
+ });
663
+
664
+ const runId = await engine.startRun({ kind: "test.fast" });
665
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
666
+
667
+ expect(run.status).toBe("succeeded");
668
+ });
669
+
670
+ it("should emit run.timeout event", async () => {
671
+ const receivedEvents: WorkflowEvent[] = [];
672
+
673
+ engine.registerWorkflow({
674
+ kind: "test.timeout.event",
675
+ name: "Test Timeout Event",
676
+ timeout: 50,
677
+ steps: [
678
+ {
679
+ key: "slow_step",
680
+ name: "Slow Step",
681
+ handler: async () => {
682
+ await new Promise((resolve) => setTimeout(resolve, 500));
683
+ return "done";
684
+ },
685
+ },
686
+ ],
687
+ });
688
+
689
+ const runId = await engine.startRun({ kind: "test.timeout.event" });
690
+ engine.subscribeToRun(runId, (event) => {
691
+ receivedEvents.push(event);
692
+ });
693
+
694
+ await engine.waitForRun(runId, { timeout: 2000 });
695
+
696
+ expect(receivedEvents.some((e) => e.eventType === "run.timeout")).toBe(
697
+ true,
698
+ );
699
+ });
700
+ });
701
+
702
+ describe("concurrency control", () => {
703
+ it("should limit concurrent runs with maxConcurrency", async () => {
704
+ const concurrentEngine = new WorkflowEngine({
705
+ storage: new MemoryStorageAdapter(),
706
+ events: new MemoryEventTransport(),
707
+ logger: new SilentLogger(),
708
+ settings: {
709
+ maxConcurrency: 2,
710
+ },
711
+ });
712
+
713
+ concurrentEngine.registerWorkflow({
714
+ kind: "test.concurrent",
715
+ name: "Test Concurrent",
716
+ steps: [
717
+ {
718
+ key: "step1",
719
+ name: "Step 1",
720
+ handler: async () => {
721
+ await new Promise((resolve) => setTimeout(resolve, 100));
722
+ return "done";
723
+ },
724
+ },
725
+ ],
726
+ });
727
+
728
+ // Start 4 runs
729
+ const runIds = await Promise.all([
730
+ concurrentEngine.startRun({ kind: "test.concurrent" }),
731
+ concurrentEngine.startRun({ kind: "test.concurrent" }),
732
+ concurrentEngine.startRun({ kind: "test.concurrent" }),
733
+ concurrentEngine.startRun({ kind: "test.concurrent" }),
734
+ ]);
735
+
736
+ // Check that only 2 are active and 2 are queued
737
+ expect(concurrentEngine.getActiveRunCount()).toBe(2);
738
+ expect(concurrentEngine.getQueuedRunCount()).toBe(2);
739
+
740
+ // Wait for all to complete
741
+ await Promise.all(
742
+ runIds.map((id) => concurrentEngine.waitForRun(id, { timeout: 2000 })),
743
+ );
744
+
745
+ expect(concurrentEngine.getActiveRunCount()).toBe(0);
746
+ expect(concurrentEngine.getQueuedRunCount()).toBe(0);
747
+
748
+ await concurrentEngine.shutdown();
749
+ });
750
+
751
+ it("should process queue in order when runs complete", async () => {
752
+ const completionOrder: string[] = [];
753
+
754
+ const concurrentEngine = new WorkflowEngine({
755
+ storage: new MemoryStorageAdapter(),
756
+ events: new MemoryEventTransport(),
757
+ logger: new SilentLogger(),
758
+ settings: {
759
+ maxConcurrency: 1,
760
+ },
761
+ });
762
+
763
+ concurrentEngine.registerWorkflow({
764
+ kind: "test.queue.order",
765
+ name: "Test Queue Order",
766
+ steps: [
767
+ {
768
+ key: "step1",
769
+ name: "Step 1",
770
+ handler: async (ctx) => {
771
+ completionOrder.push(ctx.input.id as string);
772
+ return "done";
773
+ },
774
+ },
775
+ ],
776
+ });
777
+
778
+ // Start 3 runs in order
779
+ const runIds = await Promise.all([
780
+ concurrentEngine.startRun({
781
+ kind: "test.queue.order",
782
+ input: { id: "first" },
783
+ }),
784
+ concurrentEngine.startRun({
785
+ kind: "test.queue.order",
786
+ input: { id: "second" },
787
+ }),
788
+ concurrentEngine.startRun({
789
+ kind: "test.queue.order",
790
+ input: { id: "third" },
791
+ }),
792
+ ]);
793
+
794
+ // Wait for all to complete
795
+ await Promise.all(
796
+ runIds.map((id) => concurrentEngine.waitForRun(id, { timeout: 2000 })),
797
+ );
798
+
799
+ // Should complete in order (FIFO)
800
+ expect(completionOrder).toEqual(["first", "second", "third"]);
801
+
802
+ await concurrentEngine.shutdown();
803
+ });
804
+ });
805
+
806
+ describe("initialize", () => {
807
+ it("should call storage.initialize if available", async () => {
808
+ const initFn = vi.fn();
809
+ const customStorage = new MemoryStorageAdapter();
810
+ (customStorage as any).initialize = initFn;
811
+
812
+ const eng = new WorkflowEngine({
813
+ storage: customStorage,
814
+ events,
815
+ logger: new SilentLogger(),
816
+ });
817
+
818
+ await eng.initialize();
819
+ expect(initFn).toHaveBeenCalledTimes(1);
820
+ });
821
+
822
+ it("should not throw if storage has no initialize", async () => {
823
+ await expect(engine.initialize()).resolves.not.toThrow();
824
+ });
825
+ });
826
+
827
+ describe("shutdown", () => {
828
+ it("should cancel active runs and close resources", async () => {
829
+ engine.registerWorkflow({
830
+ kind: "test.shutdown",
831
+ name: "Test Shutdown",
832
+ steps: [
833
+ {
834
+ key: "step1",
835
+ name: "Step 1",
836
+ handler: async () => {
837
+ await new Promise((resolve) => setTimeout(resolve, 5000));
838
+ return "done";
839
+ },
840
+ },
841
+ ],
842
+ });
843
+
844
+ await engine.startRun({ kind: "test.shutdown" });
845
+ expect(engine.getActiveRunCount()).toBe(1);
846
+
847
+ await engine.shutdown();
848
+ expect(engine.getActiveRunCount()).toBe(0);
849
+ });
850
+
851
+ it("should call events.close and storage.close if available", async () => {
852
+ const closeFn = vi.fn();
853
+ const storageCloseFn = vi.fn();
854
+ const customEvents = new MemoryEventTransport();
855
+ (customEvents as any).close = closeFn;
856
+ const customStorage = new MemoryStorageAdapter();
857
+ (customStorage as any).close = storageCloseFn;
858
+
859
+ const eng = new WorkflowEngine({
860
+ storage: customStorage,
861
+ events: customEvents,
862
+ logger: new SilentLogger(),
863
+ });
864
+
865
+ await eng.shutdown();
866
+ expect(closeFn).toHaveBeenCalled();
867
+ expect(storageCloseFn).toHaveBeenCalled();
868
+ });
869
+ });
870
+
871
+ describe("waitForRun", () => {
872
+ it("should throw RunNotFoundError if run does not exist", async () => {
873
+ await expect(engine.waitForRun("nonexistent")).rejects.toThrow(
874
+ "not found",
875
+ );
876
+ });
877
+
878
+ it("should timeout if run never completes", async () => {
879
+ engine.registerWorkflow({
880
+ kind: "test.wait.timeout",
881
+ name: "Test Wait Timeout",
882
+ steps: [
883
+ {
884
+ key: "step1",
885
+ name: "Step 1",
886
+ handler: async () => {
887
+ await new Promise((resolve) => setTimeout(resolve, 10000));
888
+ return "done";
889
+ },
890
+ },
891
+ ],
892
+ });
893
+
894
+ const runId = await engine.startRun({ kind: "test.wait.timeout" });
895
+
896
+ await expect(
897
+ engine.waitForRun(runId, { timeout: 100, pollInterval: 20 }),
898
+ ).rejects.toThrow("Timeout waiting for run");
899
+
900
+ await engine.cancelRun(runId);
901
+ });
902
+ });
903
+
904
+ describe("cancelRun", () => {
905
+ it("should throw RunNotFoundError for non-existent run", async () => {
906
+ await expect(engine.cancelRun("nonexistent")).rejects.toThrow(
907
+ "not found",
908
+ );
909
+ });
910
+
911
+ it("should update status even if run is not actively running", async () => {
912
+ engine.registerWorkflow({
913
+ kind: "test.cancel.inactive",
914
+ name: "Test Cancel Inactive",
915
+ steps: [
916
+ {
917
+ key: "step1",
918
+ name: "Step 1",
919
+ handler: async () => "done",
920
+ },
921
+ ],
922
+ });
923
+
924
+ const runId = await engine.startRun({ kind: "test.cancel.inactive" });
925
+ await engine.waitForRun(runId);
926
+
927
+ // Run is already complete — cancelRun is a no-op for terminal statuses
928
+ await engine.cancelRun(runId);
929
+ const run = await engine.getRunStatus(runId);
930
+ expect(run?.status).toBe("succeeded");
931
+ });
932
+ });
933
+
934
+ describe("resumeRun", () => {
935
+ it("should throw RunNotFoundError for non-existent run", async () => {
936
+ await expect(engine.resumeRun("nonexistent")).rejects.toThrow(
937
+ "not found",
938
+ );
939
+ });
940
+
941
+ it("should throw if run status is not resumable", async () => {
942
+ engine.registerWorkflow({
943
+ kind: "test.resume.completed",
944
+ name: "Test",
945
+ steps: [{ key: "s1", name: "S1", handler: async () => "done" }],
946
+ });
947
+
948
+ const runId = await engine.startRun({ kind: "test.resume.completed" });
949
+ await engine.waitForRun(runId);
950
+
951
+ await expect(engine.resumeRun(runId)).rejects.toThrow(
952
+ "Cannot resume run",
953
+ );
954
+ });
955
+
956
+ it("should throw if workflow is not registered", async () => {
957
+ // Create a run directly in storage with a kind that is not registered
958
+ const run = await storage.createRun({
959
+ kind: "unregistered.workflow",
960
+ status: "running",
961
+ input: {},
962
+ metadata: {},
963
+ context: {},
964
+ });
965
+
966
+ await expect(engine.resumeRun(run.id)).rejects.toThrow(
967
+ "is not registered",
968
+ );
969
+ });
970
+
971
+ it("should skip resume if run is already active", async () => {
972
+ engine.registerWorkflow({
973
+ kind: "test.resume.active",
974
+ name: "Test",
975
+ steps: [
976
+ {
977
+ key: "s1",
978
+ name: "S1",
979
+ handler: async () => {
980
+ await new Promise((resolve) => setTimeout(resolve, 500));
981
+ return "done";
982
+ },
983
+ },
984
+ ],
985
+ });
986
+
987
+ const runId = await engine.startRun({ kind: "test.resume.active" });
988
+ // Manually set status to running for resume check
989
+ await storage.updateRun(runId, { status: "running" });
990
+
991
+ // Attempt resume while the run is still active
992
+ const result = await engine.resumeRun(runId);
993
+ expect(result).toBe(runId);
994
+
995
+ await engine.waitForRun(runId, { timeout: 2000 });
996
+ });
997
+
998
+ it("should resume from checkpoint", async () => {
999
+ const executionOrder: string[] = [];
1000
+
1001
+ engine.registerWorkflow({
1002
+ kind: "test.resume.checkpoint",
1003
+ name: "Test Resume",
1004
+ steps: [
1005
+ {
1006
+ key: "step1",
1007
+ name: "Step 1",
1008
+ handler: async () => {
1009
+ executionOrder.push("step1");
1010
+ return "r1";
1011
+ },
1012
+ },
1013
+ {
1014
+ key: "step2",
1015
+ name: "Step 2",
1016
+ handler: async () => {
1017
+ executionOrder.push("step2");
1018
+ return "r2";
1019
+ },
1020
+ },
1021
+ ],
1022
+ });
1023
+
1024
+ // Create a run that looks interrupted with step1 already done
1025
+ const run = await storage.createRun({
1026
+ kind: "test.resume.checkpoint",
1027
+ status: "running",
1028
+ input: {},
1029
+ metadata: {},
1030
+ context: { step1: "r1" },
1031
+ });
1032
+
1033
+ const resumedId = await engine.resumeRun(run.id);
1034
+ expect(resumedId).toBe(run.id);
1035
+
1036
+ const result = await engine.waitForRun(run.id, { timeout: 2000 });
1037
+ expect(result.status).toBe("succeeded");
1038
+
1039
+ // Only step2 should have executed
1040
+ expect(executionOrder).toEqual(["step2"]);
1041
+ });
1042
+ });
1043
+
1044
+ describe("resumeAllInterrupted", () => {
1045
+ it("should resume all resumable runs", async () => {
1046
+ engine.registerWorkflow({
1047
+ kind: "test.resumeAll",
1048
+ name: "Test",
1049
+ steps: [{ key: "s1", name: "S1", handler: async () => "done" }],
1050
+ });
1051
+
1052
+ // Create two interrupted runs
1053
+ await storage.createRun({
1054
+ kind: "test.resumeAll",
1055
+ status: "running",
1056
+ input: {},
1057
+ metadata: {},
1058
+ context: {},
1059
+ });
1060
+ await storage.createRun({
1061
+ kind: "test.resumeAll",
1062
+ status: "queued",
1063
+ input: {},
1064
+ metadata: {},
1065
+ context: {},
1066
+ });
1067
+ // One completed run (should be skipped)
1068
+ await storage.createRun({
1069
+ kind: "test.resumeAll",
1070
+ status: "succeeded",
1071
+ input: {},
1072
+ metadata: {},
1073
+ context: {},
1074
+ });
1075
+
1076
+ const resumed = await engine.resumeAllInterrupted();
1077
+ expect(resumed).toHaveLength(2);
1078
+
1079
+ // Wait for them to complete
1080
+ for (const id of resumed) {
1081
+ await engine.waitForRun(id, { timeout: 2000 });
1082
+ }
1083
+ });
1084
+
1085
+ it("should skip runs for unregistered workflows", async () => {
1086
+ await storage.createRun({
1087
+ kind: "unregistered.workflow",
1088
+ status: "running",
1089
+ input: {},
1090
+ metadata: {},
1091
+ context: {},
1092
+ });
1093
+
1094
+ const resumed = await engine.resumeAllInterrupted();
1095
+ expect(resumed).toHaveLength(0);
1096
+ });
1097
+ });
1098
+
1099
+ describe("getResumableRuns", () => {
1100
+ it("should return only queued and running runs", async () => {
1101
+ await storage.createRun({
1102
+ kind: "a",
1103
+ status: "queued",
1104
+ input: {},
1105
+ metadata: {},
1106
+ context: {},
1107
+ });
1108
+ await storage.createRun({
1109
+ kind: "a",
1110
+ status: "running",
1111
+ input: {},
1112
+ metadata: {},
1113
+ context: {},
1114
+ });
1115
+ await storage.createRun({
1116
+ kind: "a",
1117
+ status: "succeeded",
1118
+ input: {},
1119
+ metadata: {},
1120
+ context: {},
1121
+ });
1122
+ await storage.createRun({
1123
+ kind: "a",
1124
+ status: "failed",
1125
+ input: {},
1126
+ metadata: {},
1127
+ context: {},
1128
+ });
1129
+
1130
+ const resumable = await engine.getResumableRuns();
1131
+ expect(resumable).toHaveLength(2);
1132
+ expect(
1133
+ resumable.every((r) => ["queued", "running"].includes(r.status)),
1134
+ ).toBe(true);
1135
+ });
1136
+ });
1137
+
1138
+ describe("delayed execution", () => {
1139
+ it("should delay workflow start", async () => {
1140
+ engine.registerWorkflow({
1141
+ kind: "test.delay",
1142
+ name: "Test Delay",
1143
+ steps: [
1144
+ {
1145
+ key: "step1",
1146
+ name: "Step 1",
1147
+ handler: async () => "done",
1148
+ },
1149
+ ],
1150
+ });
1151
+
1152
+ const start = Date.now();
1153
+ const runId = await engine.startRun({ kind: "test.delay", delay: 100 });
1154
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
1155
+
1156
+ expect(run.status).toBe("succeeded");
1157
+ expect(Date.now() - start).toBeGreaterThanOrEqual(90);
1158
+ });
1159
+ });
1160
+
1161
+ describe("getStorage / getEvents", () => {
1162
+ it("should return storage and events adapters", () => {
1163
+ expect(engine.getStorage()).toBe(storage);
1164
+ expect(engine.getEvents()).toBe(events);
1165
+ });
1166
+ });
1167
+
1168
+ describe("subscribeToRun", () => {
1169
+ it("should receive events for specific run", async () => {
1170
+ const receivedEvents: WorkflowEvent[] = [];
1171
+
1172
+ engine.registerWorkflow({
1173
+ kind: "test.sub",
1174
+ name: "Test",
1175
+ steps: [{ key: "s1", name: "S1", handler: async () => "done" }],
1176
+ });
1177
+
1178
+ const runId = await engine.startRun({ kind: "test.sub" });
1179
+ const unsub = engine.subscribeToRun(runId, (event) => {
1180
+ receivedEvents.push(event);
1181
+ });
1182
+
1183
+ await engine.waitForRun(runId);
1184
+ unsub();
1185
+
1186
+ expect(receivedEvents.length).toBeGreaterThan(0);
1187
+ expect(receivedEvents.every((e) => e.runId === runId)).toBe(true);
1188
+ });
1189
+ });
1190
+
1191
+ describe("default constructor", () => {
1192
+ it("should use defaults when no config is provided", () => {
1193
+ const defaultEngine = new WorkflowEngine();
1194
+ expect(defaultEngine.getStorage()).toBeInstanceOf(MemoryStorageAdapter);
1195
+ });
1196
+ });
1197
+
1198
+ describe("priority queues", () => {
1199
+ it("should process high priority runs first", async () => {
1200
+ const completionOrder: string[] = [];
1201
+
1202
+ const priorityEngine = new WorkflowEngine({
1203
+ storage: new MemoryStorageAdapter(),
1204
+ events: new MemoryEventTransport(),
1205
+ logger: new SilentLogger(),
1206
+ settings: {
1207
+ maxConcurrency: 1,
1208
+ },
1209
+ });
1210
+
1211
+ priorityEngine.registerWorkflow({
1212
+ kind: "test.priority",
1213
+ name: "Test Priority",
1214
+ steps: [
1215
+ {
1216
+ key: "step1",
1217
+ name: "Step 1",
1218
+ handler: async (ctx) => {
1219
+ completionOrder.push(ctx.input.id as string);
1220
+ return "done";
1221
+ },
1222
+ },
1223
+ ],
1224
+ });
1225
+
1226
+ // Start first run (occupies the single slot)
1227
+ const firstId = await priorityEngine.startRun({
1228
+ kind: "test.priority",
1229
+ input: { id: "first" },
1230
+ priority: 0,
1231
+ });
1232
+
1233
+ // Queue 3 runs with different priorities
1234
+ // Low priority first, then high priority
1235
+ const lowId = await priorityEngine.startRun({
1236
+ kind: "test.priority",
1237
+ input: { id: "low" },
1238
+ priority: 0,
1239
+ });
1240
+
1241
+ const highId = await priorityEngine.startRun({
1242
+ kind: "test.priority",
1243
+ input: { id: "high" },
1244
+ priority: 10,
1245
+ });
1246
+
1247
+ const mediumId = await priorityEngine.startRun({
1248
+ kind: "test.priority",
1249
+ input: { id: "medium" },
1250
+ priority: 5,
1251
+ });
1252
+
1253
+ // Wait for all to complete
1254
+ await Promise.all([
1255
+ priorityEngine.waitForRun(firstId, { timeout: 2000 }),
1256
+ priorityEngine.waitForRun(lowId, { timeout: 2000 }),
1257
+ priorityEngine.waitForRun(highId, { timeout: 2000 }),
1258
+ priorityEngine.waitForRun(mediumId, { timeout: 2000 }),
1259
+ ]);
1260
+
1261
+ // First should complete first (already running)
1262
+ // Then high priority, medium, low
1263
+ expect(completionOrder).toEqual(["first", "high", "medium", "low"]);
1264
+
1265
+ await priorityEngine.shutdown();
1266
+ });
1267
+
1268
+ it("should emit run.queued and run.dequeued events", async () => {
1269
+ const queuedEvents: WorkflowEvent[] = [];
1270
+ const dequeuedEvents: WorkflowEvent[] = [];
1271
+
1272
+ const priorityEngine = new WorkflowEngine({
1273
+ storage: new MemoryStorageAdapter(),
1274
+ events: new MemoryEventTransport(),
1275
+ logger: new SilentLogger(),
1276
+ settings: {
1277
+ maxConcurrency: 1,
1278
+ },
1279
+ });
1280
+
1281
+ priorityEngine.registerWorkflow({
1282
+ kind: "test.queue.events",
1283
+ name: "Test Queue Events",
1284
+ steps: [
1285
+ {
1286
+ key: "step1",
1287
+ name: "Step 1",
1288
+ handler: async () => {
1289
+ await new Promise((resolve) => setTimeout(resolve, 50));
1290
+ return "done";
1291
+ },
1292
+ },
1293
+ ],
1294
+ });
1295
+
1296
+ // Subscribe to all events
1297
+ priorityEngine.subscribeToAll((event) => {
1298
+ if (event.eventType === "run.queued") {
1299
+ queuedEvents.push(event);
1300
+ }
1301
+ if (event.eventType === "run.dequeued") {
1302
+ dequeuedEvents.push(event);
1303
+ }
1304
+ });
1305
+
1306
+ // Start 2 runs (first will run, second will queue)
1307
+ const firstId = await priorityEngine.startRun({
1308
+ kind: "test.queue.events",
1309
+ });
1310
+ const secondId = await priorityEngine.startRun({
1311
+ kind: "test.queue.events",
1312
+ });
1313
+
1314
+ // Wait for both to complete
1315
+ await Promise.all([
1316
+ priorityEngine.waitForRun(firstId, { timeout: 2000 }),
1317
+ priorityEngine.waitForRun(secondId, { timeout: 2000 }),
1318
+ ]);
1319
+
1320
+ expect(queuedEvents).toHaveLength(1);
1321
+ expect(queuedEvents[0].runId).toBe(secondId);
1322
+
1323
+ expect(dequeuedEvents).toHaveLength(1);
1324
+ expect(dequeuedEvents[0].runId).toBe(secondId);
1325
+
1326
+ await priorityEngine.shutdown();
1327
+ });
1328
+ });
1329
+
1330
+ describe("orchestrator: raceWithAbort pre-aborted signal", () => {
1331
+ it("should throw WorkflowCanceledError immediately when signal is already aborted", async () => {
1332
+ engine.registerWorkflow({
1333
+ kind: "test.preabort",
1334
+ name: "Test Pre-Abort",
1335
+ steps: [
1336
+ {
1337
+ key: "step1",
1338
+ name: "Step 1",
1339
+ handler: async () => {
1340
+ await new Promise((resolve) => setTimeout(resolve, 50));
1341
+ return "done";
1342
+ },
1343
+ },
1344
+ ],
1345
+ });
1346
+
1347
+ const runId = await engine.startRun({ kind: "test.preabort" });
1348
+
1349
+ // Cancel immediately (before step starts executing in the microtask)
1350
+ engine.cancelRun(runId);
1351
+
1352
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
1353
+ expect(run.status).toBe("canceled");
1354
+ });
1355
+ });
1356
+
1357
+ describe("orchestrator: afterRun hook error swallowing", () => {
1358
+ it("should swallow afterRun hook error on failure path and still return failed result", async () => {
1359
+ engine.registerWorkflow({
1360
+ kind: "test.hook.afterrun.error",
1361
+ name: "Test afterRun Hook Error",
1362
+ steps: [
1363
+ {
1364
+ key: "fail-step",
1365
+ name: "Failing Step",
1366
+ handler: async () => {
1367
+ throw new Error("Step failure");
1368
+ },
1369
+ },
1370
+ ],
1371
+ hooks: {
1372
+ afterRun: async () => {
1373
+ throw new Error("afterRun hook exploded");
1374
+ },
1375
+ },
1376
+ });
1377
+
1378
+ const runId = await engine.startRun({ kind: "test.hook.afterrun.error" });
1379
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
1380
+
1381
+ // The workflow should fail with the original step error, not the hook error
1382
+ expect(run.status).toBe("failed");
1383
+ expect(run.error?.message).toContain("Step failure");
1384
+ });
1385
+ });
1386
+
1387
+ describe("orchestrator: executeWithTimeout abort-during-step", () => {
1388
+ it("should cancel a step with per-step timeout when workflow-level abort fires", async () => {
1389
+ let stepStarted = false;
1390
+
1391
+ engine.registerWorkflow({
1392
+ kind: "test.timeout.abort",
1393
+ name: "Test Timeout Abort",
1394
+ steps: [
1395
+ {
1396
+ key: "long-step",
1397
+ name: "Long Step",
1398
+ timeout: 60000, // 60s per-step timeout
1399
+ handler: async () => {
1400
+ stepStarted = true;
1401
+ // This step takes a long time
1402
+ await new Promise((resolve) => setTimeout(resolve, 10000));
1403
+ return "done";
1404
+ },
1405
+ },
1406
+ ],
1407
+ });
1408
+
1409
+ const runId = await engine.startRun({ kind: "test.timeout.abort" });
1410
+
1411
+ // Wait until the step has started, then cancel
1412
+ await new Promise((resolve) => setTimeout(resolve, 50));
1413
+ expect(stepStarted).toBe(true);
1414
+
1415
+ engine.cancelRun(runId);
1416
+
1417
+ const run = await engine.waitForRun(runId, { timeout: 2000 });
1418
+ expect(run.status).toBe("canceled");
1419
+ });
1420
+ });
1421
+ });