@auto-engineer/pipeline 1.68.0 → 1.69.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 (70) 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 +29 -0
  5. package/dist/src/engine/workflow-processor.d.ts +3 -0
  6. package/dist/src/engine/workflow-processor.d.ts.map +1 -1
  7. package/dist/src/engine/workflow-processor.js +50 -7
  8. package/dist/src/engine/workflow-processor.js.map +1 -1
  9. package/dist/src/index.d.ts +0 -2
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +0 -2
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/server/phased-bridge.d.ts +13 -0
  14. package/dist/src/server/phased-bridge.d.ts.map +1 -0
  15. package/dist/src/server/phased-bridge.js +103 -0
  16. package/dist/src/server/phased-bridge.js.map +1 -0
  17. package/dist/src/server/pipeline-server.d.ts +2 -2
  18. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  19. package/dist/src/server/pipeline-server.js +18 -38
  20. package/dist/src/server/pipeline-server.js.map +1 -1
  21. package/dist/src/server/v2-runtime-bridge.d.ts +21 -0
  22. package/dist/src/server/v2-runtime-bridge.d.ts.map +1 -0
  23. package/dist/src/server/v2-runtime-bridge.js +182 -0
  24. package/dist/src/server/v2-runtime-bridge.js.map +1 -0
  25. package/dist/src/store/pipeline-event-store.d.ts.map +1 -1
  26. package/dist/src/store/pipeline-event-store.js +0 -30
  27. package/dist/src/store/pipeline-event-store.js.map +1 -1
  28. package/dist/src/store/pipeline-read-model.d.ts +0 -15
  29. package/dist/src/store/pipeline-read-model.d.ts.map +1 -1
  30. package/dist/src/store/pipeline-read-model.js +0 -49
  31. package/dist/src/store/pipeline-read-model.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/ketchup-plan.md +10 -12
  34. package/package.json +3 -3
  35. package/src/engine/workflow-processor.specs.ts +101 -0
  36. package/src/engine/workflow-processor.ts +54 -8
  37. package/src/index.ts +0 -2
  38. package/src/server/phased-bridge.specs.ts +272 -0
  39. package/src/server/phased-bridge.ts +130 -0
  40. package/src/server/pipeline-server.ts +20 -41
  41. package/src/server/v2-runtime-bridge.specs.ts +347 -0
  42. package/src/server/v2-runtime-bridge.ts +246 -0
  43. package/src/store/pipeline-event-store.specs.ts +0 -137
  44. package/src/store/pipeline-event-store.ts +0 -35
  45. package/src/store/pipeline-read-model.specs.ts +0 -567
  46. package/src/store/pipeline-read-model.ts +0 -71
  47. package/dist/src/projections/phased-execution-projection.d.ts +0 -77
  48. package/dist/src/projections/phased-execution-projection.d.ts.map +0 -1
  49. package/dist/src/projections/phased-execution-projection.js +0 -54
  50. package/dist/src/projections/phased-execution-projection.js.map +0 -1
  51. package/dist/src/projections/settled-instance-projection.d.ts +0 -67
  52. package/dist/src/projections/settled-instance-projection.d.ts.map +0 -1
  53. package/dist/src/projections/settled-instance-projection.js +0 -66
  54. package/dist/src/projections/settled-instance-projection.js.map +0 -1
  55. package/dist/src/runtime/phased-executor.d.ts +0 -34
  56. package/dist/src/runtime/phased-executor.d.ts.map +0 -1
  57. package/dist/src/runtime/phased-executor.js +0 -172
  58. package/dist/src/runtime/phased-executor.js.map +0 -1
  59. package/dist/src/runtime/settled-tracker.d.ts +0 -44
  60. package/dist/src/runtime/settled-tracker.d.ts.map +0 -1
  61. package/dist/src/runtime/settled-tracker.js +0 -170
  62. package/dist/src/runtime/settled-tracker.js.map +0 -1
  63. package/src/projections/phased-execution-projection.specs.ts +0 -202
  64. package/src/projections/phased-execution-projection.ts +0 -146
  65. package/src/projections/settled-instance-projection.specs.ts +0 -296
  66. package/src/projections/settled-instance-projection.ts +0 -160
  67. package/src/runtime/phased-executor.specs.ts +0 -680
  68. package/src/runtime/phased-executor.ts +0 -230
  69. package/src/runtime/settled-tracker.specs.ts +0 -1044
  70. package/src/runtime/settled-tracker.ts +0 -235
@@ -1,680 +0,0 @@
1
- import type { Event } from '@auto-engineer/message-bus';
2
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
- import type { ForEachPhasedDescriptor } from '../core/descriptors';
4
- import type { PhasedExecutionEvent } from '../projections/phased-execution-projection';
5
- import type { PipelineEventStoreContext } from '../store/pipeline-event-store';
6
- import { createPipelineEventStore } from '../store/pipeline-event-store';
7
- import { PhasedExecutor } from './phased-executor';
8
-
9
- interface TestItem {
10
- id: string;
11
- type: 'molecule' | 'organism' | 'page';
12
- }
13
-
14
- function createHandler(_items: TestItem[]): ForEachPhasedDescriptor {
15
- return {
16
- type: 'foreach-phased',
17
- eventType: 'ClientGenerated',
18
- itemsSelector: (e: Event) => (e.data as { components: TestItem[] }).components,
19
- phases: ['molecule', 'organism', 'page'],
20
- classifier: (item: unknown) => (item as TestItem).type,
21
- stopOnFailure: false,
22
- emitFactory: (item: unknown, _phase: string, _event: Event) => ({
23
- commandType: 'ImplementComponent',
24
- data: { filePath: (item as TestItem).id },
25
- }),
26
- completion: {
27
- successEvent: { name: 'AllComponentsImplemented' },
28
- failureEvent: { name: 'ComponentsFailed' },
29
- itemKey: (e: Event) => (e.data as { filePath?: string; id?: string }).filePath ?? (e.data as TestItem).id,
30
- },
31
- };
32
- }
33
-
34
- interface ESExecutorOptions {
35
- onEventEmit?: (event: PhasedExecutionEvent) => void;
36
- }
37
-
38
- function createESExecutor(
39
- ctx: PipelineEventStoreContext,
40
- dispatched: Array<{ commandType: string; data: unknown; correlationId: string }>,
41
- completed: Event[],
42
- options: ESExecutorOptions = {},
43
- ): PhasedExecutor {
44
- return new PhasedExecutor({
45
- readModel: ctx.readModel,
46
- onDispatch: (commandType, data, correlationId) => {
47
- dispatched.push({ commandType, data, correlationId });
48
- },
49
- onComplete: (event) => {
50
- completed.push(event);
51
- },
52
- onEventEmit: async (event) => {
53
- const data = event.data as Record<string, unknown>;
54
- const correlationId = (data.correlationId as string) ?? (data.executionId as string)?.split('-')[1] ?? 'default';
55
- await ctx.eventStore.appendToStream(`phased-${correlationId}`, [{ type: event.type, data: event.data }]);
56
- options.onEventEmit?.(event);
57
- },
58
- });
59
- }
60
-
61
- describe('PhasedExecutor', () => {
62
- let executor: PhasedExecutor;
63
- let dispatched: Array<{ commandType: string; data: unknown; correlationId: string }>;
64
- let completed: Event[];
65
- let ctx: PipelineEventStoreContext;
66
-
67
- beforeEach(() => {
68
- dispatched = [];
69
- completed = [];
70
- ctx = createPipelineEventStore();
71
- executor = createESExecutor(ctx, dispatched, completed);
72
- });
73
-
74
- afterEach(async () => {
75
- await ctx.close();
76
- });
77
-
78
- describe('phase gating', () => {
79
- it('should dispatch only first phase items initially', async () => {
80
- const items: TestItem[] = [
81
- { id: 'm1', type: 'molecule' },
82
- { id: 'm2', type: 'molecule' },
83
- { id: 'o1', type: 'organism' },
84
- { id: 'p1', type: 'page' },
85
- ];
86
- const handler = createHandler(items);
87
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
88
-
89
- await executor.startPhased(handler, event, 'c1');
90
-
91
- expect(dispatched).toHaveLength(2);
92
- expect(dispatched.map((d) => (d.data as { filePath: string }).filePath)).toEqual(['m1', 'm2']);
93
- });
94
-
95
- it('should wait for all items in phase to complete before next phase', async () => {
96
- const items: TestItem[] = [
97
- { id: 'm1', type: 'molecule' },
98
- { id: 'm2', type: 'molecule' },
99
- { id: 'o1', type: 'organism' },
100
- ];
101
- const handler = createHandler(items);
102
- executor.registerHandler(handler);
103
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
104
-
105
- await executor.startPhased(handler, event, 'c1');
106
-
107
- expect(dispatched).toHaveLength(2);
108
-
109
- await executor.onEventReceived(
110
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
111
- 'm1',
112
- );
113
-
114
- expect(dispatched).toHaveLength(2);
115
-
116
- await executor.onEventReceived(
117
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm2' } },
118
- 'm2',
119
- );
120
-
121
- expect(dispatched).toHaveLength(3);
122
- expect((dispatched[2].data as { filePath: string }).filePath).toBe('o1');
123
- });
124
-
125
- it('should skip empty phases', async () => {
126
- const items: TestItem[] = [
127
- { id: 'm1', type: 'molecule' },
128
- { id: 'p1', type: 'page' },
129
- ];
130
- const handler = createHandler(items);
131
- executor.registerHandler(handler);
132
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
133
-
134
- await executor.startPhased(handler, event, 'c1');
135
-
136
- expect(dispatched).toHaveLength(1);
137
- expect((dispatched[0].data as { filePath: string }).filePath).toBe('m1');
138
-
139
- await executor.onEventReceived(
140
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
141
- 'm1',
142
- );
143
-
144
- expect(dispatched).toHaveLength(2);
145
- expect((dispatched[1].data as { filePath: string }).filePath).toBe('p1');
146
- });
147
- });
148
-
149
- describe('completion tracking', () => {
150
- it('should emit success event when all phases complete', async () => {
151
- const items: TestItem[] = [
152
- { id: 'm1', type: 'molecule' },
153
- { id: 'o1', type: 'organism' },
154
- ];
155
- const handler = createHandler(items);
156
- executor.registerHandler(handler);
157
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
158
-
159
- await executor.startPhased(handler, event, 'c1');
160
-
161
- await executor.onEventReceived(
162
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
163
- 'm1',
164
- );
165
- await executor.onEventReceived(
166
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'o1' } },
167
- 'o1',
168
- );
169
-
170
- expect(completed).toHaveLength(1);
171
- expect(completed[0].type).toBe('AllComponentsImplemented');
172
- expect(completed[0].correlationId).toBe('c1');
173
- });
174
-
175
- it('should cleanup session after completion allowing new session with same correlationId', async () => {
176
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
177
- const handler = createHandler(items);
178
- executor.registerHandler(handler);
179
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
180
-
181
- await executor.startPhased(handler, event, 'c1');
182
- await executor.onEventReceived(
183
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
184
- 'm1',
185
- );
186
-
187
- expect(completed).toHaveLength(1);
188
-
189
- dispatched.length = 0;
190
- completed.length = 0;
191
-
192
- await executor.startPhased(handler, event, 'c1');
193
- expect(dispatched).toHaveLength(1);
194
- expect((dispatched[0].data as { filePath: string }).filePath).toBe('m1');
195
- });
196
- });
197
-
198
- describe('state queries', () => {
199
- it('should report phase completion status', async () => {
200
- const items: TestItem[] = [
201
- { id: 'm1', type: 'molecule' },
202
- { id: 'o1', type: 'organism' },
203
- ];
204
- const handler = createHandler(items);
205
- executor.registerHandler(handler);
206
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
207
-
208
- await executor.startPhased(handler, event, 'c1');
209
-
210
- expect(await executor.isPhaseComplete('c1', 'molecule')).toBe(false);
211
- expect(await executor.isPhaseComplete('c1', 'organism')).toBe(false);
212
-
213
- await executor.onEventReceived(
214
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
215
- 'm1',
216
- );
217
-
218
- expect(await executor.isPhaseComplete('c1', 'molecule')).toBe(true);
219
- expect(await executor.isPhaseComplete('c1', 'organism')).toBe(false);
220
- });
221
-
222
- it('should return false for unknown correlationId', async () => {
223
- expect(await executor.isPhaseComplete('unknown', 'molecule')).toBe(false);
224
- });
225
-
226
- it('should return false for unknown phase name', async () => {
227
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
228
- const handler = createHandler(items);
229
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
230
-
231
- await executor.startPhased(handler, event, 'c1');
232
-
233
- expect(await executor.isPhaseComplete('c1', 'nonexistent-phase')).toBe(false);
234
- });
235
-
236
- it('should return false for future phase when current phase is earlier', async () => {
237
- const items: TestItem[] = [
238
- { id: 'm1', type: 'molecule' },
239
- { id: 'p1', type: 'page' },
240
- ];
241
- const handler = createHandler(items);
242
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
243
-
244
- await executor.startPhased(handler, event, 'c1');
245
-
246
- expect(await executor.isPhaseComplete('c1', 'page')).toBe(false);
247
- });
248
-
249
- it('should check correct session when multiple sessions exist with different correlationIds', async () => {
250
- const items1: TestItem[] = [
251
- { id: 'm1', type: 'molecule' },
252
- { id: 'o1', type: 'organism' },
253
- ];
254
- const items2: TestItem[] = [
255
- { id: 'm2', type: 'molecule' },
256
- { id: 'o2', type: 'organism' },
257
- ];
258
- const handler1 = createHandler(items1);
259
- const handler2 = createHandler(items2);
260
- executor.registerHandler(handler1);
261
- executor.registerHandler(handler2);
262
-
263
- await executor.startPhased(
264
- handler1,
265
- { type: 'ClientGenerated', correlationId: 'c1', data: { components: items1 } },
266
- 'c1',
267
- );
268
- await executor.startPhased(
269
- handler2,
270
- { type: 'ClientGenerated', correlationId: 'c2', data: { components: items2 } },
271
- 'c2',
272
- );
273
-
274
- await executor.onEventReceived(
275
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
276
- 'm1',
277
- );
278
-
279
- expect(await executor.isPhaseComplete('c1', 'molecule')).toBe(true);
280
- expect(await executor.isPhaseComplete('c2', 'molecule')).toBe(false);
281
- });
282
- });
283
-
284
- describe('failure handling', () => {
285
- it('should stop on failure when stopOnFailure is true and emit failure event', async () => {
286
- const items: TestItem[] = [
287
- { id: 'm1', type: 'molecule' },
288
- { id: 'm2', type: 'molecule' },
289
- { id: 'o1', type: 'organism' },
290
- ];
291
- const handler: ForEachPhasedDescriptor = {
292
- ...createHandler(items),
293
- stopOnFailure: true,
294
- };
295
- executor.registerHandler(handler);
296
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
297
-
298
- await executor.startPhased(handler, event, 'c1');
299
-
300
- await executor.onEventReceived({ type: 'ComponentsFailed', correlationId: 'c1', data: { filePath: 'm1' } }, 'm1');
301
-
302
- expect(completed).toHaveLength(1);
303
- expect(completed[0].type).toBe('ComponentsFailed');
304
- });
305
-
306
- it('should continue on failure when stopOnFailure is false', async () => {
307
- const items: TestItem[] = [
308
- { id: 'm1', type: 'molecule' },
309
- { id: 'm2', type: 'molecule' },
310
- { id: 'o1', type: 'organism' },
311
- ];
312
- const handler = createHandler(items);
313
- executor.registerHandler(handler);
314
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
315
-
316
- await executor.startPhased(handler, event, 'c1');
317
-
318
- await executor.onEventReceived({ type: 'ComponentsFailed', correlationId: 'c1', data: { filePath: 'm1' } }, 'm1');
319
-
320
- expect(completed).toHaveLength(0);
321
-
322
- await executor.onEventReceived(
323
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm2' } },
324
- 'm2',
325
- );
326
-
327
- expect(dispatched).toHaveLength(3);
328
- });
329
-
330
- it('should cleanup session after stopOnFailure allowing new session with same correlationId', async () => {
331
- const items: TestItem[] = [
332
- { id: 'm1', type: 'molecule' },
333
- { id: 'o1', type: 'organism' },
334
- ];
335
- const handler: ForEachPhasedDescriptor = {
336
- ...createHandler(items),
337
- stopOnFailure: true,
338
- };
339
- executor.registerHandler(handler);
340
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
341
-
342
- await executor.startPhased(handler, event, 'c1');
343
- await executor.onEventReceived({ type: 'ComponentsFailed', correlationId: 'c1', data: { filePath: 'm1' } }, 'm1');
344
-
345
- expect(completed).toHaveLength(1);
346
- expect(completed[0].type).toBe('ComponentsFailed');
347
-
348
- dispatched.length = 0;
349
- completed.length = 0;
350
-
351
- await executor.startPhased(handler, event, 'c1');
352
- expect(dispatched).toHaveLength(1);
353
- });
354
- });
355
-
356
- describe('concurrent sessions', () => {
357
- it('should track sessions independently by correlationId', async () => {
358
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
359
- const handler = createHandler(items);
360
- executor.registerHandler(handler);
361
-
362
- await executor.startPhased(
363
- handler,
364
- { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } },
365
- 'c1',
366
- );
367
- await executor.startPhased(
368
- handler,
369
- { type: 'ClientGenerated', correlationId: 'c2', data: { components: items } },
370
- 'c2',
371
- );
372
-
373
- expect(dispatched).toHaveLength(2);
374
- expect(dispatched[0].correlationId).toBe('c1');
375
- expect(dispatched[1].correlationId).toBe('c2');
376
-
377
- await executor.onEventReceived(
378
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
379
- 'm1',
380
- );
381
-
382
- expect(completed).toHaveLength(1);
383
- expect(completed[0].correlationId).toBe('c1');
384
-
385
- await executor.onEventReceived(
386
- { type: 'ComponentImplemented', correlationId: 'c2', data: { filePath: 'm1' } },
387
- 'm1',
388
- );
389
-
390
- expect(completed).toHaveLength(2);
391
- expect(completed[1].correlationId).toBe('c2');
392
- });
393
-
394
- it('should not interfere between concurrent sessions with different items', async () => {
395
- const items1: TestItem[] = [
396
- { id: 'a1', type: 'molecule' },
397
- { id: 'a2', type: 'organism' },
398
- ];
399
- const items2: TestItem[] = [
400
- { id: 'b1', type: 'molecule' },
401
- { id: 'b2', type: 'page' },
402
- ];
403
- const handler1 = createHandler(items1);
404
- const handler2 = createHandler(items2);
405
- executor.registerHandler(handler1);
406
- executor.registerHandler(handler2);
407
-
408
- await executor.startPhased(
409
- handler1,
410
- { type: 'ClientGenerated', correlationId: 'c1', data: { components: items1 } },
411
- 'c1',
412
- );
413
- await executor.startPhased(
414
- handler2,
415
- { type: 'ClientGenerated', correlationId: 'c2', data: { components: items2 } },
416
- 'c2',
417
- );
418
-
419
- expect(dispatched).toHaveLength(2);
420
-
421
- await executor.onEventReceived(
422
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'a1' } },
423
- 'a1',
424
- );
425
-
426
- expect(dispatched).toHaveLength(3);
427
- expect((dispatched[2].data as { filePath: string }).filePath).toBe('a2');
428
- expect(dispatched[2].correlationId).toBe('c1');
429
-
430
- await executor.onEventReceived(
431
- { type: 'ComponentImplemented', correlationId: 'c2', data: { filePath: 'b1' } },
432
- 'b1',
433
- );
434
-
435
- expect(dispatched).toHaveLength(4);
436
- expect((dispatched[3].data as { filePath: string }).filePath).toBe('b2');
437
- expect(dispatched[3].correlationId).toBe('c2');
438
- });
439
- });
440
-
441
- describe('event deduplication', () => {
442
- it('should ignore duplicate events for already completed items', async () => {
443
- const items: TestItem[] = [
444
- { id: 'm1', type: 'molecule' },
445
- { id: 'm2', type: 'molecule' },
446
- { id: 'o1', type: 'organism' },
447
- ];
448
- const handler = createHandler(items);
449
- executor.registerHandler(handler);
450
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
451
-
452
- await executor.startPhased(handler, event, 'c1');
453
-
454
- await executor.onEventReceived(
455
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
456
- 'm1',
457
- );
458
-
459
- expect(dispatched).toHaveLength(2);
460
-
461
- await executor.onEventReceived(
462
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
463
- 'm1',
464
- );
465
-
466
- expect(dispatched).toHaveLength(2);
467
- });
468
- });
469
-
470
- describe('dispatch race condition', () => {
471
- it('should not advance phase when item completes before next item is dispatched', async () => {
472
- const items: TestItem[] = [
473
- { id: 'm1', type: 'molecule' },
474
- { id: 'm2', type: 'molecule' },
475
- { id: 'o1', type: 'organism' },
476
- ];
477
- const handler = createHandler(items);
478
- const raceDispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
479
- const raceCompleted: Event[] = [];
480
- let firstDispatchSeen = false;
481
-
482
- const raceExecutor = new PhasedExecutor({
483
- readModel: ctx.readModel,
484
- onDispatch: (commandType, data, correlationId) => {
485
- raceDispatched.push({ commandType, data, correlationId });
486
- },
487
- onComplete: (event) => {
488
- raceCompleted.push(event);
489
- },
490
- onEventEmit: async (event) => {
491
- const data = event.data as Record<string, unknown>;
492
- const correlationId =
493
- (data.correlationId as string) ?? (data.executionId as string)?.split('-')[1] ?? 'default';
494
- await ctx.eventStore.appendToStream(`phased-${correlationId}`, [{ type: event.type, data: event.data }]);
495
-
496
- if (event.type === 'PhasedItemDispatched' && data.itemKey === 'm1' && !firstDispatchSeen) {
497
- firstDispatchSeen = true;
498
- await raceExecutor.onEventReceived(
499
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
500
- 'm1',
501
- );
502
- }
503
- },
504
- });
505
- raceExecutor.registerHandler(handler);
506
-
507
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
508
- await raceExecutor.startPhased(handler, event, 'c1');
509
-
510
- const dispatchedPaths = raceDispatched.map((d) => (d.data as { filePath: string }).filePath);
511
- expect(dispatchedPaths).toEqual(['m1', 'm2']);
512
- expect(raceCompleted).toHaveLength(0);
513
- });
514
- });
515
-
516
- describe('event edge cases', () => {
517
- it('should ignore events with undefined correlationId', async () => {
518
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
519
- const handler = createHandler(items);
520
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
521
-
522
- await executor.startPhased(handler, event, 'c1');
523
-
524
- expect(dispatched).toHaveLength(1);
525
-
526
- await executor.onEventReceived({ type: 'ComponentImplemented', data: { filePath: 'm1' } }, 'm1');
527
-
528
- expect(dispatched).toHaveLength(1);
529
- expect(completed).toHaveLength(0);
530
- });
531
-
532
- it('should ignore events with empty correlationId', async () => {
533
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
534
- const handler = createHandler(items);
535
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
536
-
537
- await executor.startPhased(handler, event, 'c1');
538
-
539
- expect(dispatched).toHaveLength(1);
540
-
541
- await executor.onEventReceived(
542
- { type: 'ComponentImplemented', correlationId: '', data: { filePath: 'm1' } },
543
- 'm1',
544
- );
545
-
546
- expect(dispatched).toHaveLength(1);
547
- expect(completed).toHaveLength(0);
548
- });
549
-
550
- it('should ignore events with unknown itemKey', async () => {
551
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
552
- const handler = createHandler(items);
553
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
554
-
555
- await executor.startPhased(handler, event, 'c1');
556
-
557
- await executor.onEventReceived(
558
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'unknown' } },
559
- 'unknown',
560
- );
561
-
562
- expect(dispatched).toHaveLength(1);
563
- expect(completed).toHaveLength(0);
564
- });
565
- });
566
-
567
- describe('event emission', () => {
568
- let emittedEvents: PhasedExecutionEvent[];
569
-
570
- beforeEach(() => {
571
- emittedEvents = [];
572
- executor = createESExecutor(ctx, dispatched, completed, {
573
- onEventEmit: (event) => {
574
- emittedEvents.push(event);
575
- },
576
- });
577
- });
578
-
579
- it('should emit PhasedExecutionStarted when starting', async () => {
580
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
581
- const handler = createHandler(items);
582
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
583
-
584
- await executor.startPhased(handler, event, 'c1');
585
-
586
- const startEvent = emittedEvents.find((e) => e.type === 'PhasedExecutionStarted');
587
- expect(startEvent).toBeDefined();
588
- expect(startEvent?.data.correlationId).toBe('c1');
589
- expect(startEvent?.data.items).toHaveLength(1);
590
- });
591
-
592
- it('should emit PhasedItemDispatched when dispatching items', async () => {
593
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
594
- const handler = createHandler(items);
595
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
596
-
597
- await executor.startPhased(handler, event, 'c1');
598
-
599
- const dispatchEvents = emittedEvents.filter((e) => e.type === 'PhasedItemDispatched');
600
- expect(dispatchEvents).toHaveLength(1);
601
- expect(dispatchEvents[0].data.itemKey).toBe('m1');
602
- });
603
-
604
- it('should emit PhasedItemCompleted when item completes', async () => {
605
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
606
- const handler = createHandler(items);
607
- executor.registerHandler(handler);
608
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
609
-
610
- await executor.startPhased(handler, event, 'c1');
611
- await executor.onEventReceived(
612
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
613
- 'm1',
614
- );
615
-
616
- const completeEvents = emittedEvents.filter((e) => e.type === 'PhasedItemCompleted');
617
- expect(completeEvents).toHaveLength(1);
618
- expect(completeEvents[0].data.itemKey).toBe('m1');
619
- });
620
-
621
- it('should emit PhasedPhaseAdvanced when advancing phases', async () => {
622
- const items: TestItem[] = [
623
- { id: 'm1', type: 'molecule' },
624
- { id: 'o1', type: 'organism' },
625
- ];
626
- const handler = createHandler(items);
627
- executor.registerHandler(handler);
628
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
629
-
630
- await executor.startPhased(handler, event, 'c1');
631
- await executor.onEventReceived(
632
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
633
- 'm1',
634
- );
635
-
636
- const advanceEvents = emittedEvents.filter((e) => e.type === 'PhasedPhaseAdvanced');
637
- expect(advanceEvents).toHaveLength(1);
638
- expect(advanceEvents[0].data.fromPhase).toBe(0);
639
- expect(advanceEvents[0].data.toPhase).toBe(1);
640
- });
641
-
642
- it('should emit PhasedExecutionCompleted on success', async () => {
643
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
644
- const handler = createHandler(items);
645
- executor.registerHandler(handler);
646
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
647
-
648
- await executor.startPhased(handler, event, 'c1');
649
- await executor.onEventReceived(
650
- { type: 'ComponentImplemented', correlationId: 'c1', data: { filePath: 'm1' } },
651
- 'm1',
652
- );
653
-
654
- const completedEvents = emittedEvents.filter((e) => e.type === 'PhasedExecutionCompleted');
655
- expect(completedEvents).toHaveLength(1);
656
- expect(completedEvents[0].data.success).toBe(true);
657
- });
658
-
659
- it('should emit PhasedItemFailed and PhasedExecutionCompleted on failure', async () => {
660
- const items: TestItem[] = [{ id: 'm1', type: 'molecule' }];
661
- const handler: ForEachPhasedDescriptor = {
662
- ...createHandler(items),
663
- stopOnFailure: true,
664
- };
665
- executor.registerHandler(handler);
666
- const event: Event = { type: 'ClientGenerated', correlationId: 'c1', data: { components: items } };
667
-
668
- await executor.startPhased(handler, event, 'c1');
669
- await executor.onEventReceived({ type: 'ComponentsFailed', correlationId: 'c1', data: { filePath: 'm1' } }, 'm1');
670
-
671
- const failedEvents = emittedEvents.filter((e) => e.type === 'PhasedItemFailed');
672
- expect(failedEvents).toHaveLength(1);
673
- expect(failedEvents[0].data.itemKey).toBe('m1');
674
-
675
- const completedEvents = emittedEvents.filter((e) => e.type === 'PhasedExecutionCompleted');
676
- expect(completedEvents).toHaveLength(1);
677
- expect(completedEvents[0].data.success).toBe(false);
678
- });
679
- });
680
- });