@auto-engineer/pipeline 1.65.0 → 1.67.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.
Files changed (120) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +135 -0
  5. package/dist/src/builder/define-v2.d.ts +101 -0
  6. package/dist/src/builder/define-v2.d.ts.map +1 -0
  7. package/dist/src/builder/define-v2.js +209 -0
  8. package/dist/src/builder/define-v2.js.map +1 -0
  9. package/dist/src/engine/command-dispatcher.d.ts +31 -0
  10. package/dist/src/engine/command-dispatcher.d.ts.map +1 -0
  11. package/dist/src/engine/command-dispatcher.js +26 -0
  12. package/dist/src/engine/command-dispatcher.js.map +1 -0
  13. package/dist/src/engine/event-router.d.ts +21 -0
  14. package/dist/src/engine/event-router.d.ts.map +1 -0
  15. package/dist/src/engine/event-router.js +22 -0
  16. package/dist/src/engine/event-router.js.map +1 -0
  17. package/dist/src/engine/index.d.ts +15 -0
  18. package/dist/src/engine/index.d.ts.map +1 -0
  19. package/dist/src/engine/index.js +15 -0
  20. package/dist/src/engine/index.js.map +1 -0
  21. package/dist/src/engine/pipeline-engine.d.ts +37 -0
  22. package/dist/src/engine/pipeline-engine.d.ts.map +1 -0
  23. package/dist/src/engine/pipeline-engine.js +53 -0
  24. package/dist/src/engine/pipeline-engine.js.map +1 -0
  25. package/dist/src/engine/projections/item-status.d.ts +9 -0
  26. package/dist/src/engine/projections/item-status.d.ts.map +1 -0
  27. package/dist/src/engine/projections/item-status.js +9 -0
  28. package/dist/src/engine/projections/item-status.js.map +1 -0
  29. package/dist/src/engine/projections/latest-run.d.ts +9 -0
  30. package/dist/src/engine/projections/latest-run.d.ts.map +1 -0
  31. package/dist/src/engine/projections/latest-run.js +9 -0
  32. package/dist/src/engine/projections/latest-run.js.map +1 -0
  33. package/dist/src/engine/projections/message-log.d.ts +9 -0
  34. package/dist/src/engine/projections/message-log.d.ts.map +1 -0
  35. package/dist/src/engine/projections/message-log.js +10 -0
  36. package/dist/src/engine/projections/message-log.js.map +1 -0
  37. package/dist/src/engine/projections/node-status.d.ts +9 -0
  38. package/dist/src/engine/projections/node-status.d.ts.map +1 -0
  39. package/dist/src/engine/projections/node-status.js +9 -0
  40. package/dist/src/engine/projections/node-status.js.map +1 -0
  41. package/dist/src/engine/projections/stats.d.ts +9 -0
  42. package/dist/src/engine/projections/stats.d.ts.map +1 -0
  43. package/dist/src/engine/projections/stats.js +9 -0
  44. package/dist/src/engine/projections/stats.js.map +1 -0
  45. package/dist/src/engine/sqlite-consumer.d.ts +11 -0
  46. package/dist/src/engine/sqlite-consumer.d.ts.map +1 -0
  47. package/dist/src/engine/sqlite-consumer.js +27 -0
  48. package/dist/src/engine/sqlite-consumer.js.map +1 -0
  49. package/dist/src/engine/sqlite-store.d.ts +10 -0
  50. package/dist/src/engine/sqlite-store.d.ts.map +1 -0
  51. package/dist/src/engine/sqlite-store.js +14 -0
  52. package/dist/src/engine/sqlite-store.js.map +1 -0
  53. package/dist/src/engine/workflow-processor.d.ts +20 -0
  54. package/dist/src/engine/workflow-processor.d.ts.map +1 -0
  55. package/dist/src/engine/workflow-processor.js +36 -0
  56. package/dist/src/engine/workflow-processor.js.map +1 -0
  57. package/dist/src/engine/workflows/await-workflow.d.ts +33 -0
  58. package/dist/src/engine/workflows/await-workflow.d.ts.map +1 -0
  59. package/dist/src/engine/workflows/await-workflow.js +45 -0
  60. package/dist/src/engine/workflows/await-workflow.js.map +1 -0
  61. package/dist/src/engine/workflows/phased-workflow.d.ts +64 -0
  62. package/dist/src/engine/workflows/phased-workflow.d.ts.map +1 -0
  63. package/dist/src/engine/workflows/phased-workflow.js +103 -0
  64. package/dist/src/engine/workflows/phased-workflow.js.map +1 -0
  65. package/dist/src/engine/workflows/settled-workflow.d.ts +62 -0
  66. package/dist/src/engine/workflows/settled-workflow.d.ts.map +1 -0
  67. package/dist/src/engine/workflows/settled-workflow.js +92 -0
  68. package/dist/src/engine/workflows/settled-workflow.js.map +1 -0
  69. package/dist/src/graph/types.d.ts +1 -1
  70. package/dist/src/graph/types.d.ts.map +1 -1
  71. package/dist/src/index.d.ts +2 -0
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +2 -0
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/server/pipeline-server-v2.d.ts +48 -0
  76. package/dist/src/server/pipeline-server-v2.d.ts.map +1 -0
  77. package/dist/src/server/pipeline-server-v2.js +61 -0
  78. package/dist/src/server/pipeline-server-v2.js.map +1 -0
  79. package/dist/src/server/pipeline-server.d.ts +5 -1
  80. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  81. package/dist/src/server/pipeline-server.js +71 -10
  82. package/dist/src/server/pipeline-server.js.map +1 -1
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/ketchup-plan.md +13 -0
  85. package/package.json +3 -3
  86. package/src/builder/define-v2.specs.ts +236 -0
  87. package/src/builder/define-v2.ts +351 -0
  88. package/src/engine/command-dispatcher.specs.ts +62 -0
  89. package/src/engine/command-dispatcher.ts +46 -0
  90. package/src/engine/event-router.specs.ts +75 -0
  91. package/src/engine/event-router.ts +36 -0
  92. package/src/engine/index.ts +39 -0
  93. package/src/engine/pipeline-engine-e2e.specs.ts +776 -0
  94. package/src/engine/pipeline-engine.integration.specs.ts +126 -0
  95. package/src/engine/pipeline-engine.specs.ts +70 -0
  96. package/src/engine/pipeline-engine.ts +82 -0
  97. package/src/engine/projections/item-status.ts +11 -0
  98. package/src/engine/projections/latest-run.ts +10 -0
  99. package/src/engine/projections/message-log.ts +11 -0
  100. package/src/engine/projections/node-status.ts +10 -0
  101. package/src/engine/projections/projections.specs.ts +176 -0
  102. package/src/engine/projections/stats.ts +10 -0
  103. package/src/engine/sqlite-consumer.specs.ts +42 -0
  104. package/src/engine/sqlite-consumer.ts +34 -0
  105. package/src/engine/sqlite-store.specs.ts +46 -0
  106. package/src/engine/sqlite-store.ts +21 -0
  107. package/src/engine/workflow-processor.specs.ts +37 -0
  108. package/src/engine/workflow-processor.ts +57 -0
  109. package/src/engine/workflows/await-workflow.specs.ts +104 -0
  110. package/src/engine/workflows/await-workflow.ts +66 -0
  111. package/src/engine/workflows/phased-workflow.specs.ts +383 -0
  112. package/src/engine/workflows/phased-workflow.ts +153 -0
  113. package/src/engine/workflows/settled-workflow.specs.ts +364 -0
  114. package/src/engine/workflows/settled-workflow.ts +139 -0
  115. package/src/graph/types.ts +1 -1
  116. package/src/index.ts +2 -0
  117. package/src/server/pipeline-server-v2.specs.ts +91 -0
  118. package/src/server/pipeline-server-v2.ts +70 -0
  119. package/src/server/pipeline-server.specs.ts +327 -134
  120. package/src/server/pipeline-server.ts +77 -11
@@ -0,0 +1,776 @@
1
+ import { defineV2, toGraph } from '../builder/define-v2.js';
2
+ import { containsSubsequence } from '../testing/snapshot-compare.js';
3
+ import { createPipelineEngine } from './pipeline-engine.js';
4
+ import { createAwaitWorkflow } from './workflows/await-workflow.js';
5
+ import { createPhasedWorkflow } from './workflows/phased-workflow.js';
6
+ import { createSettledWorkflow } from './workflows/settled-workflow.js';
7
+
8
+ type Event = { type: string; data: Record<string, unknown> };
9
+ type Command = { type: string; data: Record<string, unknown> };
10
+ type CommandHandler = (cmd: Command) => Event[] | Promise<Event[]>;
11
+
12
+ type EmitMapping = {
13
+ eventType: string;
14
+ commands: Array<{
15
+ commandType: string;
16
+ data: Record<string, unknown> | ((event: Event) => Record<string, unknown>);
17
+ }>;
18
+ };
19
+
20
+ type WorkflowRegistration = {
21
+ id: string;
22
+ workflow: {
23
+ decide: (input: Event, state: unknown) => Event | Event[];
24
+ evolve: (state: unknown, event: Event) => unknown;
25
+ initialState: () => unknown;
26
+ };
27
+ inputEvents: string[];
28
+ };
29
+
30
+ async function createTestPipeline(config: {
31
+ handlers: Record<string, CommandHandler>;
32
+ emitMappings?: EmitMapping[];
33
+ workflows?: WorkflowRegistration[];
34
+ }) {
35
+ const engine = await createPipelineEngine();
36
+ const events: Event[] = [];
37
+ engine.onEvent((event) => events.push(event));
38
+
39
+ for (const [type, handler] of Object.entries(config.handlers)) {
40
+ engine.registerCommandHandler(type, handler);
41
+ }
42
+ for (const mapping of config.emitMappings ?? []) {
43
+ engine.registerEmitMapping(mapping);
44
+ }
45
+ for (const workflow of config.workflows ?? []) {
46
+ engine.registerWorkflow(workflow);
47
+ }
48
+
49
+ return {
50
+ dispatch: (cmd: Command) => engine.dispatch(cmd),
51
+ replay: async (commands: Command[]) => {
52
+ for (const cmd of commands) {
53
+ await engine.dispatch(cmd);
54
+ }
55
+ },
56
+ events: () => [...events],
57
+ eventTypes: () => events.map((e) => e.type),
58
+ eventsOfType: (type: string) => events.filter((e) => e.type === type),
59
+ hasEvent: (type: string) => events.some((e) => e.type === type),
60
+ close: () => engine.close(),
61
+ };
62
+ }
63
+
64
+ describe('PipelineEngine E2E', () => {
65
+ describe('emit chains', () => {
66
+ it('dispatches downstream command when event fires', async () => {
67
+ const pipeline = await createTestPipeline({
68
+ handlers: {
69
+ StartBuild: () => [{ type: 'BuildStarted', data: {} }],
70
+ RunTests: () => [{ type: 'TestsPassed', data: {} }],
71
+ },
72
+ emitMappings: [{ eventType: 'BuildStarted', commands: [{ commandType: 'RunTests', data: {} }] }],
73
+ });
74
+
75
+ await pipeline.dispatch({ type: 'StartBuild', data: {} });
76
+
77
+ expect(pipeline.eventTypes()).toEqual(['BuildStarted', 'TestsPassed']);
78
+ await pipeline.close();
79
+ });
80
+
81
+ it('chains three hops with data factory functions', async () => {
82
+ const pipeline = await createTestPipeline({
83
+ handlers: {
84
+ A: () => [{ type: 'ADone', data: { path: '/src' } }],
85
+ B: (cmd) => [{ type: 'BDone', data: { target: cmd.data.target } }],
86
+ C: () => [{ type: 'CDone', data: {} }],
87
+ },
88
+ emitMappings: [
89
+ { eventType: 'ADone', commands: [{ commandType: 'B', data: (e) => ({ target: e.data.path }) }] },
90
+ { eventType: 'BDone', commands: [{ commandType: 'C', data: {} }] },
91
+ ],
92
+ });
93
+
94
+ await pipeline.dispatch({ type: 'A', data: {} });
95
+
96
+ expect(pipeline.eventTypes()).toEqual(['ADone', 'BDone', 'CDone']);
97
+ expect(pipeline.eventsOfType('BDone')[0].data).toEqual({ target: '/src' });
98
+ await pipeline.close();
99
+ });
100
+ });
101
+
102
+ describe('settled workflow', () => {
103
+ it('all commands succeed produces AllSettled', async () => {
104
+ const pipeline = await createTestPipeline({
105
+ handlers: {
106
+ RunChecks: () => [
107
+ { type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA', 'CheckB'] } },
108
+ ],
109
+ CheckA: () => [{ type: 'CommandCompleted', data: { commandType: 'CheckA', result: 'success', event: {} } }],
110
+ CheckB: () => [{ type: 'CommandCompleted', data: { commandType: 'CheckB', result: 'success', event: {} } }],
111
+ },
112
+ emitMappings: [
113
+ {
114
+ eventType: 'StartSettled',
115
+ commands: [
116
+ { commandType: 'CheckA', data: {} },
117
+ { commandType: 'CheckB', data: {} },
118
+ ],
119
+ },
120
+ ],
121
+ workflows: [
122
+ {
123
+ id: 'settled-checks',
124
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA', 'CheckB'] }),
125
+ inputEvents: ['StartSettled', 'CommandCompleted'],
126
+ },
127
+ ],
128
+ });
129
+
130
+ await pipeline.dispatch({ type: 'RunChecks', data: {} });
131
+
132
+ expect(pipeline.hasEvent('AllSettled')).toBe(true);
133
+ expect(pipeline.eventTypes()).toEqual(['StartSettled', 'CommandCompleted', 'CommandCompleted', 'AllSettled']);
134
+ await pipeline.close();
135
+ });
136
+
137
+ it('retry succeeds after initial failure', async () => {
138
+ let checkACallCount = 0;
139
+ const pipeline = await createTestPipeline({
140
+ handlers: {
141
+ RunChecks: () => [
142
+ { type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA', 'CheckB'] } },
143
+ ],
144
+ CheckA: () => {
145
+ checkACallCount++;
146
+ if (checkACallCount === 1) {
147
+ return [
148
+ {
149
+ type: 'CommandCompleted',
150
+ data: { commandType: 'CheckA', result: 'failure', event: { error: 'flaky' } },
151
+ },
152
+ ];
153
+ }
154
+ return [{ type: 'CommandCompleted', data: { commandType: 'CheckA', result: 'success', event: {} } }];
155
+ },
156
+ CheckB: () => [{ type: 'CommandCompleted', data: { commandType: 'CheckB', result: 'success', event: {} } }],
157
+ },
158
+ emitMappings: [
159
+ {
160
+ eventType: 'StartSettled',
161
+ commands: [
162
+ { commandType: 'CheckA', data: {} },
163
+ { commandType: 'CheckB', data: {} },
164
+ ],
165
+ },
166
+ {
167
+ eventType: 'RetryCommands',
168
+ commands: [
169
+ { commandType: 'CheckA', data: {} },
170
+ { commandType: 'CheckB', data: {} },
171
+ ],
172
+ },
173
+ ],
174
+ workflows: [
175
+ {
176
+ id: 'settled-checks',
177
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA', 'CheckB'], maxRetries: 3 }),
178
+ inputEvents: ['StartSettled', 'CommandCompleted'],
179
+ },
180
+ ],
181
+ });
182
+
183
+ await pipeline.dispatch({ type: 'RunChecks', data: {} });
184
+
185
+ expect(containsSubsequence(pipeline.eventTypes(), ['StartSettled', 'RetryCommands', 'AllSettled'])).toBe(true);
186
+ expect(pipeline.hasEvent('AllSettled')).toBe(true);
187
+ await pipeline.close();
188
+ });
189
+
190
+ it('max retries exhausted produces SettledFailed', async () => {
191
+ const pipeline = await createTestPipeline({
192
+ handlers: {
193
+ RunChecks: () => [{ type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA'] } }],
194
+ CheckA: () => [
195
+ {
196
+ type: 'CommandCompleted',
197
+ data: { commandType: 'CheckA', result: 'failure', event: { error: 'broken' } },
198
+ },
199
+ ],
200
+ },
201
+ emitMappings: [
202
+ { eventType: 'StartSettled', commands: [{ commandType: 'CheckA', data: {} }] },
203
+ { eventType: 'RetryCommands', commands: [{ commandType: 'CheckA', data: {} }] },
204
+ ],
205
+ workflows: [
206
+ {
207
+ id: 'settled-checks',
208
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA'], maxRetries: 2 }),
209
+ inputEvents: ['StartSettled', 'CommandCompleted'],
210
+ },
211
+ ],
212
+ });
213
+
214
+ await pipeline.dispatch({ type: 'RunChecks', data: {} });
215
+
216
+ expect(pipeline.hasEvent('SettledFailed')).toBe(true);
217
+ expect(pipeline.hasEvent('AllSettled')).toBe(false);
218
+ await pipeline.close();
219
+ });
220
+ });
221
+
222
+ describe('phased workflow', () => {
223
+ it('items execute in phase order', async () => {
224
+ const executionOrder: string[] = [];
225
+ const pipeline = await createTestPipeline({
226
+ handlers: {
227
+ RunImport: () => [
228
+ {
229
+ type: 'StartPhased',
230
+ data: {
231
+ correlationId: 'c1',
232
+ items: [
233
+ { key: 'a', phase: 'validate' },
234
+ { key: 'b', phase: 'validate' },
235
+ { key: 'c', phase: 'import' },
236
+ ],
237
+ phases: ['validate', 'import'],
238
+ stopOnFailure: false,
239
+ },
240
+ },
241
+ ],
242
+ ProcessItem: (cmd) => {
243
+ executionOrder.push(cmd.data.itemKey as string);
244
+ return [{ type: 'ItemCompleted', data: { itemKey: cmd.data.itemKey, result: {} } }];
245
+ },
246
+ },
247
+ emitMappings: [
248
+ {
249
+ eventType: 'DispatchItem',
250
+ commands: [
251
+ {
252
+ commandType: 'ProcessItem',
253
+ data: (e) => ({ itemKey: e.data.itemKey, phase: e.data.phase }),
254
+ },
255
+ ],
256
+ },
257
+ ],
258
+ workflows: [
259
+ {
260
+ id: 'phased-import',
261
+ workflow: createPhasedWorkflow(),
262
+ inputEvents: ['StartPhased', 'ItemCompleted', 'ItemFailed'],
263
+ },
264
+ ],
265
+ });
266
+
267
+ await pipeline.dispatch({ type: 'RunImport', data: {} });
268
+
269
+ expect(executionOrder).toEqual(['a', 'b', 'c']);
270
+ expect(pipeline.hasEvent('PhasedCompleted')).toBe(true);
271
+ expect(pipeline.eventTypes()).toEqual([
272
+ 'StartPhased',
273
+ 'DispatchItem',
274
+ 'ItemCompleted',
275
+ 'DispatchItem',
276
+ 'ItemCompleted',
277
+ 'DispatchItem',
278
+ 'ItemCompleted',
279
+ 'PhasedCompleted',
280
+ ]);
281
+ await pipeline.close();
282
+ });
283
+
284
+ it('stopOnFailure halts before next phase', async () => {
285
+ const executionOrder: string[] = [];
286
+ const pipeline = await createTestPipeline({
287
+ handlers: {
288
+ RunImport: () => [
289
+ {
290
+ type: 'StartPhased',
291
+ data: {
292
+ correlationId: 'c1',
293
+ items: [
294
+ { key: 'a', phase: 'validate' },
295
+ { key: 'b', phase: 'import' },
296
+ ],
297
+ phases: ['validate', 'import'],
298
+ stopOnFailure: true,
299
+ },
300
+ },
301
+ ],
302
+ ProcessItem: (cmd) => {
303
+ executionOrder.push(cmd.data.itemKey as string);
304
+ if (cmd.data.itemKey === 'a') {
305
+ return [{ type: 'ItemFailed', data: { itemKey: 'a', error: { reason: 'invalid' } } }];
306
+ }
307
+ return [{ type: 'ItemCompleted', data: { itemKey: cmd.data.itemKey, result: {} } }];
308
+ },
309
+ },
310
+ emitMappings: [
311
+ {
312
+ eventType: 'DispatchItem',
313
+ commands: [
314
+ {
315
+ commandType: 'ProcessItem',
316
+ data: (e) => ({ itemKey: e.data.itemKey, phase: e.data.phase }),
317
+ },
318
+ ],
319
+ },
320
+ ],
321
+ workflows: [
322
+ {
323
+ id: 'phased-import',
324
+ workflow: createPhasedWorkflow(),
325
+ inputEvents: ['StartPhased', 'ItemCompleted', 'ItemFailed'],
326
+ },
327
+ ],
328
+ });
329
+
330
+ await pipeline.dispatch({ type: 'RunImport', data: {} });
331
+
332
+ expect(executionOrder).toEqual(['a']);
333
+ expect(pipeline.hasEvent('PhasedFailed')).toBe(true);
334
+ expect(pipeline.hasEvent('PhasedCompleted')).toBe(false);
335
+ await pipeline.close();
336
+ });
337
+ });
338
+
339
+ describe('await workflow', () => {
340
+ it('all keys complete produces AwaitCompleted', async () => {
341
+ const pipeline = await createTestPipeline({
342
+ handlers: {
343
+ LoadData: () => [{ type: 'StartAwait', data: { correlationId: 'c1', keys: ['users', 'roles'] } }],
344
+ FetchUsers: () => [{ type: 'KeyCompleted', data: { key: 'users', result: { count: 10 } } }],
345
+ FetchRoles: () => [{ type: 'KeyCompleted', data: { key: 'roles', result: { count: 5 } } }],
346
+ },
347
+ emitMappings: [
348
+ {
349
+ eventType: 'StartAwait',
350
+ commands: [
351
+ { commandType: 'FetchUsers', data: {} },
352
+ { commandType: 'FetchRoles', data: {} },
353
+ ],
354
+ },
355
+ ],
356
+ workflows: [
357
+ {
358
+ id: 'await-data',
359
+ workflow: createAwaitWorkflow(),
360
+ inputEvents: ['StartAwait', 'KeyCompleted'],
361
+ },
362
+ ],
363
+ });
364
+
365
+ await pipeline.dispatch({ type: 'LoadData', data: {} });
366
+
367
+ expect(pipeline.hasEvent('AwaitCompleted')).toBe(true);
368
+ const completed = pipeline.eventsOfType('AwaitCompleted')[0];
369
+ expect(completed.data.results).toEqual({
370
+ users: { count: 10 },
371
+ roles: { count: 5 },
372
+ });
373
+ expect(pipeline.eventTypes()).toEqual(['StartAwait', 'KeyCompleted', 'KeyCompleted', 'AwaitCompleted']);
374
+ await pipeline.close();
375
+ });
376
+ });
377
+
378
+ describe('graph visualization', () => {
379
+ it('toGraph produces correct nodes and edges for all registration types', () => {
380
+ const pipeline = defineV2('full-pipeline')
381
+ .settled(['CheckA', 'CheckB'])
382
+ .on('BuildCompleted')
383
+ .emit('RunTests', {})
384
+ .on('TestsStarted')
385
+ .handle(() => [{ type: 'TestsHandled', data: {} }])
386
+ .on('ImportReady')
387
+ .forEach()
388
+ .groupInto(['validate', 'import'])
389
+ .process()
390
+ .on('DataReady')
391
+ .run(['users', 'roles'])
392
+ .awaitAll()
393
+ .build();
394
+
395
+ const graph = toGraph(pipeline);
396
+
397
+ const nodeIds = graph.nodes.map((n) => n.id).sort();
398
+ expect(nodeIds).toEqual([
399
+ 'await:users,roles',
400
+ 'cmd:CheckA',
401
+ 'cmd:CheckB',
402
+ 'cmd:RunTests',
403
+ 'evt:BuildCompleted',
404
+ 'evt:DataReady',
405
+ 'evt:ImportReady',
406
+ 'evt:TestsStarted',
407
+ 'handler:TestsStarted',
408
+ 'phased:validate,import',
409
+ 'settled:CheckA,CheckB',
410
+ ]);
411
+
412
+ const nodeTypes = new Set(graph.nodes.map((n) => n.type));
413
+ expect(nodeTypes).toEqual(new Set(['event', 'command', 'settled', 'phased', 'await']));
414
+
415
+ const edgeSet = graph.edges.map((e) => `${e.from}->${e.to}`).sort();
416
+ expect(edgeSet).toEqual([
417
+ 'cmd:CheckA->settled:CheckA,CheckB',
418
+ 'cmd:CheckB->settled:CheckA,CheckB',
419
+ 'evt:BuildCompleted->cmd:RunTests',
420
+ 'evt:DataReady->await:users,roles',
421
+ 'evt:ImportReady->phased:validate,import',
422
+ 'evt:TestsStarted->handler:TestsStarted',
423
+ ]);
424
+ });
425
+ });
426
+
427
+ describe('multi-archetype combined pipeline', () => {
428
+ it('chains settled → phased → await in a single dispatch', async () => {
429
+ const pipeline = await createTestPipeline({
430
+ handlers: {
431
+ RunPipeline: () => [
432
+ { type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA', 'CheckB'] } },
433
+ ],
434
+ CheckA: () => [{ type: 'CommandCompleted', data: { commandType: 'CheckA', result: 'success', event: {} } }],
435
+ CheckB: () => [{ type: 'CommandCompleted', data: { commandType: 'CheckB', result: 'success', event: {} } }],
436
+ TriggerPhased: () => [
437
+ {
438
+ type: 'StartPhased',
439
+ data: {
440
+ correlationId: 'c2',
441
+ items: [
442
+ { key: 'x', phase: 'transform' },
443
+ { key: 'y', phase: 'load' },
444
+ ],
445
+ phases: ['transform', 'load'],
446
+ stopOnFailure: false,
447
+ },
448
+ },
449
+ ],
450
+ ProcessItem: (cmd) => [{ type: 'ItemCompleted', data: { itemKey: cmd.data.itemKey, result: {} } }],
451
+ TriggerAwait: () => [{ type: 'StartAwait', data: { correlationId: 'c3', keys: ['alpha', 'beta'] } }],
452
+ FetchAlpha: () => [{ type: 'KeyCompleted', data: { key: 'alpha', result: { v: 1 } } }],
453
+ FetchBeta: () => [{ type: 'KeyCompleted', data: { key: 'beta', result: { v: 2 } } }],
454
+ },
455
+ emitMappings: [
456
+ {
457
+ eventType: 'StartSettled',
458
+ commands: [
459
+ { commandType: 'CheckA', data: {} },
460
+ { commandType: 'CheckB', data: {} },
461
+ ],
462
+ },
463
+ { eventType: 'AllSettled', commands: [{ commandType: 'TriggerPhased', data: {} }] },
464
+ {
465
+ eventType: 'DispatchItem',
466
+ commands: [
467
+ {
468
+ commandType: 'ProcessItem',
469
+ data: (e) => ({ itemKey: e.data.itemKey, phase: e.data.phase }),
470
+ },
471
+ ],
472
+ },
473
+ { eventType: 'PhasedCompleted', commands: [{ commandType: 'TriggerAwait', data: {} }] },
474
+ {
475
+ eventType: 'StartAwait',
476
+ commands: [
477
+ { commandType: 'FetchAlpha', data: {} },
478
+ { commandType: 'FetchBeta', data: {} },
479
+ ],
480
+ },
481
+ ],
482
+ workflows: [
483
+ {
484
+ id: 'settled-checks',
485
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA', 'CheckB'] }),
486
+ inputEvents: ['StartSettled', 'CommandCompleted'],
487
+ },
488
+ {
489
+ id: 'phased-etl',
490
+ workflow: createPhasedWorkflow(),
491
+ inputEvents: ['StartPhased', 'ItemCompleted', 'ItemFailed'],
492
+ },
493
+ {
494
+ id: 'await-fetch',
495
+ workflow: createAwaitWorkflow(),
496
+ inputEvents: ['StartAwait', 'KeyCompleted'],
497
+ },
498
+ ],
499
+ });
500
+
501
+ await pipeline.dispatch({ type: 'RunPipeline', data: {} });
502
+
503
+ expect(
504
+ containsSubsequence(pipeline.eventTypes(), [
505
+ 'StartSettled',
506
+ 'AllSettled',
507
+ 'StartPhased',
508
+ 'PhasedCompleted',
509
+ 'StartAwait',
510
+ 'AwaitCompleted',
511
+ ]),
512
+ ).toBe(true);
513
+ await pipeline.close();
514
+ });
515
+ });
516
+
517
+ describe('replay', () => {
518
+ it('reproduces event sequence from command log', async () => {
519
+ const combinedConfig = {
520
+ handlers: {
521
+ RunPipeline: () => [
522
+ { type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA', 'CheckB'] } },
523
+ ],
524
+ CheckA: () => [
525
+ { type: 'CommandCompleted', data: { commandType: 'CheckA', result: 'success' as const, event: {} } },
526
+ ],
527
+ CheckB: () => [
528
+ { type: 'CommandCompleted', data: { commandType: 'CheckB', result: 'success' as const, event: {} } },
529
+ ],
530
+ TriggerPhased: () => [
531
+ {
532
+ type: 'StartPhased',
533
+ data: {
534
+ correlationId: 'c2',
535
+ items: [
536
+ { key: 'x', phase: 'transform' },
537
+ { key: 'y', phase: 'load' },
538
+ ],
539
+ phases: ['transform', 'load'],
540
+ stopOnFailure: false,
541
+ },
542
+ },
543
+ ],
544
+ ProcessItem: (cmd: Command) => [{ type: 'ItemCompleted', data: { itemKey: cmd.data.itemKey, result: {} } }],
545
+ TriggerAwait: () => [{ type: 'StartAwait', data: { correlationId: 'c3', keys: ['alpha', 'beta'] } }],
546
+ FetchAlpha: () => [{ type: 'KeyCompleted', data: { key: 'alpha', result: { v: 1 } } }],
547
+ FetchBeta: () => [{ type: 'KeyCompleted', data: { key: 'beta', result: { v: 2 } } }],
548
+ },
549
+ emitMappings: [
550
+ {
551
+ eventType: 'StartSettled',
552
+ commands: [
553
+ { commandType: 'CheckA', data: {} },
554
+ { commandType: 'CheckB', data: {} },
555
+ ],
556
+ },
557
+ { eventType: 'AllSettled', commands: [{ commandType: 'TriggerPhased', data: {} }] },
558
+ {
559
+ eventType: 'DispatchItem',
560
+ commands: [
561
+ {
562
+ commandType: 'ProcessItem',
563
+ data: (e: Event) => ({ itemKey: e.data.itemKey, phase: e.data.phase }),
564
+ },
565
+ ],
566
+ },
567
+ { eventType: 'PhasedCompleted', commands: [{ commandType: 'TriggerAwait', data: {} }] },
568
+ {
569
+ eventType: 'StartAwait',
570
+ commands: [
571
+ { commandType: 'FetchAlpha', data: {} },
572
+ { commandType: 'FetchBeta', data: {} },
573
+ ],
574
+ },
575
+ ] as EmitMapping[],
576
+ workflows: [
577
+ {
578
+ id: 'settled-checks',
579
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA', 'CheckB'] }),
580
+ inputEvents: ['StartSettled', 'CommandCompleted'],
581
+ },
582
+ {
583
+ id: 'phased-etl',
584
+ workflow: createPhasedWorkflow(),
585
+ inputEvents: ['StartPhased', 'ItemCompleted', 'ItemFailed'],
586
+ },
587
+ {
588
+ id: 'await-fetch',
589
+ workflow: createAwaitWorkflow(),
590
+ inputEvents: ['StartAwait', 'KeyCompleted'],
591
+ },
592
+ ],
593
+ };
594
+
595
+ const pipeline = await createTestPipeline(combinedConfig);
596
+
597
+ await pipeline.replay([{ type: 'RunPipeline', data: {} }]);
598
+
599
+ expect(pipeline.hasEvent('AwaitCompleted')).toBe(true);
600
+ expect(
601
+ containsSubsequence(pipeline.eventTypes(), [
602
+ 'StartSettled',
603
+ 'AllSettled',
604
+ 'StartPhased',
605
+ 'PhasedCompleted',
606
+ 'StartAwait',
607
+ 'AwaitCompleted',
608
+ ]),
609
+ ).toBe(true);
610
+ await pipeline.close();
611
+ });
612
+ });
613
+
614
+ describe('parallel emit mapping', () => {
615
+ it('emit mapping dispatches commands in parallel', async () => {
616
+ const startTimes: Record<string, number> = {};
617
+ const endTimes: Record<string, number> = {};
618
+
619
+ const pipeline = await createTestPipeline({
620
+ handlers: {
621
+ Scatter: () => [{ type: 'ScatterDone', data: {} }],
622
+ TaskA: async () => {
623
+ startTimes.A = Date.now();
624
+ await new Promise((r) => setTimeout(r, 50));
625
+ endTimes.A = Date.now();
626
+ return [{ type: 'TaskADone', data: {} }];
627
+ },
628
+ TaskB: async () => {
629
+ startTimes.B = Date.now();
630
+ await new Promise((r) => setTimeout(r, 50));
631
+ endTimes.B = Date.now();
632
+ return [{ type: 'TaskBDone', data: {} }];
633
+ },
634
+ },
635
+ emitMappings: [
636
+ {
637
+ eventType: 'ScatterDone',
638
+ commands: [
639
+ { commandType: 'TaskA', data: {} },
640
+ { commandType: 'TaskB', data: {} },
641
+ ],
642
+ },
643
+ ],
644
+ });
645
+
646
+ await pipeline.dispatch({ type: 'Scatter', data: {} });
647
+
648
+ expect(startTimes.B).toBeLessThan(endTimes.A);
649
+ expect(pipeline.hasEvent('TaskADone')).toBe(true);
650
+ expect(pipeline.hasEvent('TaskBDone')).toBe(true);
651
+ await pipeline.close();
652
+ });
653
+ });
654
+
655
+ describe('parallel settled workflow', () => {
656
+ it('settled scatter-gather runs checks in parallel', async () => {
657
+ const startTimes: Record<string, number> = {};
658
+ const endTimes: Record<string, number> = {};
659
+
660
+ const pipeline = await createTestPipeline({
661
+ handlers: {
662
+ RunChecks: () => [
663
+ { type: 'StartSettled', data: { correlationId: 'c1', commandTypes: ['CheckA', 'CheckB'] } },
664
+ ],
665
+ CheckA: async () => {
666
+ startTimes.A = Date.now();
667
+ await new Promise((r) => setTimeout(r, 50));
668
+ endTimes.A = Date.now();
669
+ return [{ type: 'CommandCompleted', data: { commandType: 'CheckA', result: 'success', event: {} } }];
670
+ },
671
+ CheckB: async () => {
672
+ startTimes.B = Date.now();
673
+ await new Promise((r) => setTimeout(r, 50));
674
+ endTimes.B = Date.now();
675
+ return [{ type: 'CommandCompleted', data: { commandType: 'CheckB', result: 'success', event: {} } }];
676
+ },
677
+ },
678
+ emitMappings: [
679
+ {
680
+ eventType: 'StartSettled',
681
+ commands: [
682
+ { commandType: 'CheckA', data: {} },
683
+ { commandType: 'CheckB', data: {} },
684
+ ],
685
+ },
686
+ ],
687
+ workflows: [
688
+ {
689
+ id: 'settled-checks',
690
+ workflow: createSettledWorkflow({ commandTypes: ['CheckA', 'CheckB'] }),
691
+ inputEvents: ['StartSettled', 'CommandCompleted'],
692
+ },
693
+ ],
694
+ });
695
+
696
+ await pipeline.dispatch({ type: 'RunChecks', data: {} });
697
+
698
+ expect(startTimes.B).toBeLessThan(endTimes.A);
699
+ expect(pipeline.hasEvent('AllSettled')).toBe(true);
700
+ await pipeline.close();
701
+ });
702
+ });
703
+
704
+ describe('concurrency', () => {
705
+ it('processes multiple concurrent dispatches in parallel', async () => {
706
+ const startTimes: Record<string, number> = {};
707
+ const endTimes: Record<string, number> = {};
708
+
709
+ const pipeline = await createTestPipeline({
710
+ handlers: {
711
+ SlowA: async () => {
712
+ startTimes.A = Date.now();
713
+ await new Promise((r) => setTimeout(r, 50));
714
+ endTimes.A = Date.now();
715
+ return [{ type: 'ADone', data: {} }];
716
+ },
717
+ SlowB: async () => {
718
+ startTimes.B = Date.now();
719
+ await new Promise((r) => setTimeout(r, 50));
720
+ endTimes.B = Date.now();
721
+ return [{ type: 'BDone', data: {} }];
722
+ },
723
+ },
724
+ });
725
+
726
+ await Promise.all([
727
+ pipeline.dispatch({ type: 'SlowA', data: {} }),
728
+ pipeline.dispatch({ type: 'SlowB', data: {} }),
729
+ ]);
730
+
731
+ expect(pipeline.hasEvent('ADone')).toBe(true);
732
+ expect(pipeline.hasEvent('BDone')).toBe(true);
733
+ expect(startTimes.B).toBeLessThan(endTimes.A);
734
+ await pipeline.close();
735
+ });
736
+
737
+ it('accepts new commands while a handler is awaiting', async () => {
738
+ let resolveGate: (() => void) | undefined;
739
+ const gate = new Promise<void>((resolve) => {
740
+ resolveGate = resolve;
741
+ });
742
+ const executionLog: string[] = [];
743
+
744
+ const pipeline = await createTestPipeline({
745
+ handlers: {
746
+ SlowCommand: async () => {
747
+ executionLog.push('slow-start');
748
+ await gate;
749
+ executionLog.push('slow-end');
750
+ return [{ type: 'SlowDone', data: {} }];
751
+ },
752
+ FastCommand: () => {
753
+ executionLog.push('fast');
754
+ return [{ type: 'FastDone', data: {} }];
755
+ },
756
+ },
757
+ });
758
+
759
+ const slowPromise = pipeline.dispatch({ type: 'SlowCommand', data: {} });
760
+ await new Promise((r) => setTimeout(r, 0));
761
+
762
+ await pipeline.dispatch({ type: 'FastCommand', data: {} });
763
+
764
+ expect(executionLog).toEqual(['slow-start', 'fast']);
765
+ expect(pipeline.hasEvent('FastDone')).toBe(true);
766
+ expect(pipeline.hasEvent('SlowDone')).toBe(false);
767
+
768
+ resolveGate!();
769
+ await slowPromise;
770
+
771
+ expect(executionLog).toEqual(['slow-start', 'fast', 'slow-end']);
772
+ expect(pipeline.hasEvent('SlowDone')).toBe(true);
773
+ await pipeline.close();
774
+ });
775
+ });
776
+ });