@multiplier-labs/stepflow 0.3.4 → 0.3.6

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