@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
package/ketchup-plan.md CHANGED
@@ -2,10 +2,23 @@
2
2
 
3
3
  ## TODO
4
4
 
5
+ ### Session-Based Status Tracking (Bursts S-1 to S-4) ✅
6
+
7
+ - [x] Burst S-1: E2E test — sub-commands overwrite session status (b8246300)
8
+ - [x] Burst S-2: E2E test — sub-command items counted under unified view (b8246300)
9
+ - [x] Burst S-3: Add sessionId and RestartPipeline (b8246300)
10
+ - [x] Burst S-4: Track status under sessionId (b8246300)
11
+
5
12
  ### Graph Rendering Fix (Burst 106)
6
13
 
7
14
  - [ ] Burst 106: Show source commands whose events are listened to by the pipeline [depends: none]
8
15
 
16
+ ### Parallel Scatter-Gather in Event Router (Bursts P-1 to P-3)
17
+
18
+ - [x] Burst P-1: Test that emit mapping commands run in parallel [depends: none]
19
+ - [x] Burst P-2: Fix event router to use Promise.all [depends: P-1]
20
+ - [x] Burst P-3: Settled scatter-gather proves parallel [depends: P-2]
21
+
9
22
  ### Phase 11: 100% Test Coverage (Bursts 93-102)
10
23
 
11
24
  **Goal**: Achieve 100% test coverage by testing uncovered code or removing dead code.
package/package.json CHANGED
@@ -13,8 +13,8 @@
13
13
  "get-port": "^7.1.0",
14
14
  "jose": "^5.9.6",
15
15
  "nanoid": "^5.0.0",
16
- "@auto-engineer/file-store": "1.65.0",
17
- "@auto-engineer/message-bus": "1.65.0"
16
+ "@auto-engineer/file-store": "1.67.0",
17
+ "@auto-engineer/message-bus": "1.67.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/cors": "^2.8.17",
@@ -23,7 +23,7 @@
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
- "version": "1.65.0",
26
+ "version": "1.67.0",
27
27
  "scripts": {
28
28
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
29
29
  "test": "vitest run --reporter=dot",
@@ -0,0 +1,236 @@
1
+ import { defineV2, toGraph } from './define-v2';
2
+
3
+ describe('define-v2', () => {
4
+ it('exposes pipeline name', () => {
5
+ const pipeline = defineV2('my-pipeline').build();
6
+ expect(pipeline.name).toBe('my-pipeline');
7
+ });
8
+
9
+ describe('on().emit()', () => {
10
+ it('produces EmitRegistration for single emit', () => {
11
+ const pipeline = defineV2('test-pipeline').on('OrderCreated').emit('ProcessPayment', { amount: 100 }).build();
12
+
13
+ expect(pipeline.registrations).toEqual([
14
+ {
15
+ type: 'emit',
16
+ eventType: 'OrderCreated',
17
+ commands: [{ commandType: 'ProcessPayment', data: { amount: 100 } }],
18
+ },
19
+ ]);
20
+ });
21
+
22
+ it('produces EmitRegistration with multiple commands', () => {
23
+ const pipeline = defineV2('test-pipeline')
24
+ .on('OrderCreated')
25
+ .emit('ProcessPayment', { amount: 100 })
26
+ .emit('SendConfirmation', { template: 'order' })
27
+ .build();
28
+
29
+ expect(pipeline.registrations).toEqual([
30
+ {
31
+ type: 'emit',
32
+ eventType: 'OrderCreated',
33
+ commands: [
34
+ { commandType: 'ProcessPayment', data: { amount: 100 } },
35
+ { commandType: 'SendConfirmation', data: { template: 'order' } },
36
+ ],
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it('supports factory function for data', () => {
42
+ const factory = (event: Record<string, unknown>) => ({ id: event.id });
43
+ const pipeline = defineV2('test-pipeline').on('OrderCreated').emit('ProcessPayment', factory).build();
44
+
45
+ expect(pipeline.registrations[0]).toEqual({
46
+ type: 'emit',
47
+ eventType: 'OrderCreated',
48
+ commands: [{ commandType: 'ProcessPayment', data: factory }],
49
+ });
50
+ });
51
+
52
+ it('chains multiple on().emit() blocks', () => {
53
+ const pipeline = defineV2('test-pipeline')
54
+ .on('OrderCreated')
55
+ .emit('ProcessPayment', { amount: 100 })
56
+ .on('PaymentProcessed')
57
+ .emit('ShipOrder', { warehouse: 'main' })
58
+ .build();
59
+
60
+ expect(pipeline.registrations).toEqual([
61
+ {
62
+ type: 'emit',
63
+ eventType: 'OrderCreated',
64
+ commands: [{ commandType: 'ProcessPayment', data: { amount: 100 } }],
65
+ },
66
+ {
67
+ type: 'emit',
68
+ eventType: 'PaymentProcessed',
69
+ commands: [{ commandType: 'ShipOrder', data: { warehouse: 'main' } }],
70
+ },
71
+ ]);
72
+ });
73
+ });
74
+
75
+ describe('on().handle()', () => {
76
+ it('produces CustomHandlerRegistration', () => {
77
+ const handler = () => [{ type: 'Result', data: {} }];
78
+ const pipeline = defineV2('test-pipeline').on('OrderCreated').handle(handler).build();
79
+
80
+ expect(pipeline.registrations).toEqual([
81
+ {
82
+ type: 'custom',
83
+ eventType: 'OrderCreated',
84
+ handler,
85
+ },
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe('settled()', () => {
91
+ it('produces SettledRegistration', () => {
92
+ const pipeline = defineV2('test').settled(['CheckTests', 'CheckTypes', 'CheckLint']).build();
93
+ expect(pipeline.registrations).toEqual([
94
+ { type: 'settled', commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'] },
95
+ ]);
96
+ });
97
+
98
+ it('supports maxRetries option', () => {
99
+ const pipeline = defineV2('test').settled(['A', 'B']).maxRetries(5).build();
100
+ expect(pipeline.registrations).toEqual([{ type: 'settled', commandTypes: ['A', 'B'], maxRetries: 5 }]);
101
+ });
102
+ });
103
+
104
+ describe('forEach().groupInto().process()', () => {
105
+ it('produces PhasedRegistration', () => {
106
+ const pipeline = defineV2('test')
107
+ .on('ComponentsGenerated')
108
+ .forEach()
109
+ .groupInto(['molecule', 'organism', 'page'])
110
+ .process()
111
+ .build();
112
+ expect(pipeline.registrations).toEqual([
113
+ {
114
+ type: 'phased',
115
+ eventType: 'ComponentsGenerated',
116
+ phases: ['molecule', 'organism', 'page'],
117
+ stopOnFailure: false,
118
+ },
119
+ ]);
120
+ });
121
+
122
+ it('supports stopOnFailure', () => {
123
+ const pipeline = defineV2('test')
124
+ .on('ComponentsGenerated')
125
+ .forEach()
126
+ .groupInto(['a', 'b'])
127
+ .process()
128
+ .stopOnFailure()
129
+ .build();
130
+ expect(pipeline.registrations).toEqual([
131
+ {
132
+ type: 'phased',
133
+ eventType: 'ComponentsGenerated',
134
+ phases: ['a', 'b'],
135
+ stopOnFailure: true,
136
+ },
137
+ ]);
138
+ });
139
+ });
140
+
141
+ describe('run().awaitAll()', () => {
142
+ it('produces AwaitRegistration', () => {
143
+ const pipeline = defineV2('test').on('BatchStarted').run(['fetchUsers', 'fetchRoles']).awaitAll().build();
144
+ expect(pipeline.registrations).toEqual([
145
+ {
146
+ type: 'await',
147
+ eventType: 'BatchStarted',
148
+ keys: ['fetchUsers', 'fetchRoles'],
149
+ },
150
+ ]);
151
+ });
152
+ });
153
+
154
+ describe('toGraph()', () => {
155
+ it('converts emit registration to graph nodes and edges', () => {
156
+ const pipeline = defineV2('test')
157
+ .on('OrderCreated')
158
+ .emit('ProcessPayment', { amount: 100 })
159
+ .emit('SendEmail', { template: 'order' })
160
+ .build();
161
+
162
+ const graph = toGraph(pipeline);
163
+
164
+ expect(graph.nodes).toEqual(
165
+ expect.arrayContaining([
166
+ expect.objectContaining({ id: 'evt:OrderCreated', type: 'event' }),
167
+ expect.objectContaining({ id: 'cmd:ProcessPayment', type: 'command' }),
168
+ expect.objectContaining({ id: 'cmd:SendEmail', type: 'command' }),
169
+ ]),
170
+ );
171
+ expect(graph.edges).toEqual(
172
+ expect.arrayContaining([
173
+ { from: 'evt:OrderCreated', to: 'cmd:ProcessPayment' },
174
+ { from: 'evt:OrderCreated', to: 'cmd:SendEmail' },
175
+ ]),
176
+ );
177
+ });
178
+
179
+ it('converts settled registration to graph nodes', () => {
180
+ const pipeline = defineV2('test').settled(['CheckTests', 'CheckTypes']).build();
181
+
182
+ const graph = toGraph(pipeline);
183
+
184
+ expect(graph.nodes).toEqual(
185
+ expect.arrayContaining([
186
+ expect.objectContaining({ id: 'cmd:CheckTests', type: 'command' }),
187
+ expect.objectContaining({ id: 'cmd:CheckTypes', type: 'command' }),
188
+ expect.objectContaining({ id: 'settled:CheckTests,CheckTypes', type: 'settled' }),
189
+ ]),
190
+ );
191
+ expect(graph.edges).toEqual(
192
+ expect.arrayContaining([
193
+ { from: 'cmd:CheckTests', to: 'settled:CheckTests,CheckTypes' },
194
+ { from: 'cmd:CheckTypes', to: 'settled:CheckTests,CheckTypes' },
195
+ ]),
196
+ );
197
+ });
198
+
199
+ it('converts phased registration to graph nodes', () => {
200
+ const pipeline = defineV2('test')
201
+ .on('ComponentsGenerated')
202
+ .forEach()
203
+ .groupInto(['molecule', 'organism'])
204
+ .process()
205
+ .build();
206
+
207
+ const graph = toGraph(pipeline);
208
+
209
+ expect(graph.nodes).toEqual(
210
+ expect.arrayContaining([
211
+ expect.objectContaining({ id: 'evt:ComponentsGenerated', type: 'event' }),
212
+ expect.objectContaining({ type: 'phased' }),
213
+ ]),
214
+ );
215
+ });
216
+
217
+ it('converts await registration to graph nodes', () => {
218
+ const pipeline = defineV2('test').on('BatchStarted').run(['fetchUsers', 'fetchRoles']).awaitAll().build();
219
+
220
+ const graph = toGraph(pipeline);
221
+
222
+ expect(graph.nodes).toEqual(
223
+ expect.arrayContaining([
224
+ expect.objectContaining({ id: 'evt:BatchStarted', type: 'event' }),
225
+ expect.objectContaining({ type: 'await' }),
226
+ ]),
227
+ );
228
+ });
229
+
230
+ it('returns empty graph for empty pipeline', () => {
231
+ const pipeline = defineV2('test').build();
232
+ const graph = toGraph(pipeline);
233
+ expect(graph).toEqual({ nodes: [], edges: [] });
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,351 @@
1
+ import type { GraphEdge, GraphIR, GraphNode, NodeType } from '../graph/types';
2
+
3
+ type EmitRegistration = {
4
+ type: 'emit';
5
+ eventType: string;
6
+ commands: Array<{
7
+ commandType: string;
8
+ data: Record<string, unknown> | ((event: Record<string, unknown>) => Record<string, unknown>);
9
+ }>;
10
+ };
11
+
12
+ type CustomHandlerRegistration = {
13
+ type: 'custom';
14
+ eventType: string;
15
+ handler: (event: {
16
+ type: string;
17
+ data: Record<string, unknown>;
18
+ }) =>
19
+ | Array<{ type: string; data: Record<string, unknown> }>
20
+ | Promise<Array<{ type: string; data: Record<string, unknown> }>>;
21
+ };
22
+
23
+ export type SettledRegistration = {
24
+ type: 'settled';
25
+ commandTypes: string[];
26
+ maxRetries?: number;
27
+ };
28
+
29
+ export type PhasedRegistration = {
30
+ type: 'phased';
31
+ eventType: string;
32
+ phases: string[];
33
+ stopOnFailure: boolean;
34
+ };
35
+
36
+ export type AwaitRegistration = {
37
+ type: 'await';
38
+ eventType: string;
39
+ keys: string[];
40
+ };
41
+
42
+ type Registration =
43
+ | EmitRegistration
44
+ | CustomHandlerRegistration
45
+ | SettledRegistration
46
+ | PhasedRegistration
47
+ | AwaitRegistration;
48
+
49
+ export type PipelineV2 = {
50
+ name: string;
51
+ registrations: Registration[];
52
+ };
53
+
54
+ type EmitChain = {
55
+ emit(
56
+ commandType: string,
57
+ data: Record<string, unknown> | ((event: Record<string, unknown>) => Record<string, unknown>),
58
+ ): EmitChain;
59
+ on(eventType: string): TriggerBuilder;
60
+ build(): PipelineV2;
61
+ };
62
+
63
+ type HandleChain = {
64
+ on(eventType: string): TriggerBuilder;
65
+ build(): PipelineV2;
66
+ };
67
+
68
+ type ProcessChain = {
69
+ stopOnFailure(): ProcessChain;
70
+ on(eventType: string): TriggerBuilder;
71
+ build(): PipelineV2;
72
+ };
73
+
74
+ type GroupIntoChain = {
75
+ process(): ProcessChain;
76
+ };
77
+
78
+ type ForEachChain = {
79
+ groupInto(phases: string[]): GroupIntoChain;
80
+ };
81
+
82
+ type AwaitAllChain = {
83
+ on(eventType: string): TriggerBuilder;
84
+ build(): PipelineV2;
85
+ };
86
+
87
+ type RunChain = {
88
+ awaitAll(): AwaitAllChain;
89
+ };
90
+
91
+ type TriggerBuilder = {
92
+ emit(
93
+ commandType: string,
94
+ data: Record<string, unknown> | ((event: Record<string, unknown>) => Record<string, unknown>),
95
+ ): EmitChain;
96
+ handle(
97
+ handler: (event: {
98
+ type: string;
99
+ data: Record<string, unknown>;
100
+ }) =>
101
+ | Array<{ type: string; data: Record<string, unknown> }>
102
+ | Promise<Array<{ type: string; data: Record<string, unknown> }>>,
103
+ ): HandleChain;
104
+ forEach(): ForEachChain;
105
+ run(keys: string[]): RunChain;
106
+ };
107
+
108
+ type SettledChain = {
109
+ maxRetries(n: number): SettledChain;
110
+ on(eventType: string): TriggerBuilder;
111
+ settled(commandTypes: string[]): SettledChain;
112
+ build(): PipelineV2;
113
+ };
114
+
115
+ type PipelineV2Builder = {
116
+ on(eventType: string): TriggerBuilder;
117
+ settled(commandTypes: string[]): SettledChain;
118
+ build(): PipelineV2;
119
+ };
120
+
121
+ export function defineV2(name: string): PipelineV2Builder {
122
+ const registrations: Registration[] = [];
123
+
124
+ function createEmitChain(registration: EmitRegistration): EmitChain {
125
+ return {
126
+ emit(commandType, data) {
127
+ registration.commands.push({ commandType, data });
128
+ return createEmitChain(registration);
129
+ },
130
+ on(eventType) {
131
+ return createTriggerBuilder(eventType);
132
+ },
133
+ build() {
134
+ return { name, registrations };
135
+ },
136
+ };
137
+ }
138
+
139
+ function createHandleChain(): HandleChain {
140
+ return {
141
+ on(eventType) {
142
+ return createTriggerBuilder(eventType);
143
+ },
144
+ build() {
145
+ return { name, registrations };
146
+ },
147
+ };
148
+ }
149
+
150
+ function createProcessChain(registration: PhasedRegistration): ProcessChain {
151
+ return {
152
+ stopOnFailure() {
153
+ registration.stopOnFailure = true;
154
+ return createProcessChain(registration);
155
+ },
156
+ on(eventType) {
157
+ return createTriggerBuilder(eventType);
158
+ },
159
+ build() {
160
+ return { name, registrations };
161
+ },
162
+ };
163
+ }
164
+
165
+ function createForEachChain(eventType: string): ForEachChain {
166
+ return {
167
+ groupInto(phases) {
168
+ return {
169
+ process() {
170
+ const registration: PhasedRegistration = {
171
+ type: 'phased',
172
+ eventType,
173
+ phases,
174
+ stopOnFailure: false,
175
+ };
176
+ registrations.push(registration);
177
+ return createProcessChain(registration);
178
+ },
179
+ };
180
+ },
181
+ };
182
+ }
183
+
184
+ function createAwaitAllChain(): AwaitAllChain {
185
+ return {
186
+ on(eventType) {
187
+ return createTriggerBuilder(eventType);
188
+ },
189
+ build() {
190
+ return { name, registrations };
191
+ },
192
+ };
193
+ }
194
+
195
+ function createRunChain(eventType: string, keys: string[]): RunChain {
196
+ return {
197
+ awaitAll() {
198
+ const registration: AwaitRegistration = {
199
+ type: 'await',
200
+ eventType,
201
+ keys,
202
+ };
203
+ registrations.push(registration);
204
+ return createAwaitAllChain();
205
+ },
206
+ };
207
+ }
208
+
209
+ function createTriggerBuilder(eventType: string): TriggerBuilder {
210
+ return {
211
+ emit(commandType, data) {
212
+ const registration: EmitRegistration = {
213
+ type: 'emit',
214
+ eventType,
215
+ commands: [{ commandType, data }],
216
+ };
217
+ registrations.push(registration);
218
+ return createEmitChain(registration);
219
+ },
220
+ handle(handler) {
221
+ const registration: CustomHandlerRegistration = {
222
+ type: 'custom',
223
+ eventType,
224
+ handler,
225
+ };
226
+ registrations.push(registration);
227
+ return createHandleChain();
228
+ },
229
+ forEach() {
230
+ return createForEachChain(eventType);
231
+ },
232
+ run(keys) {
233
+ return createRunChain(eventType, keys);
234
+ },
235
+ };
236
+ }
237
+
238
+ function createSettledChain(registration: SettledRegistration): SettledChain {
239
+ return {
240
+ maxRetries(n) {
241
+ registration.maxRetries = n;
242
+ return createSettledChain(registration);
243
+ },
244
+ on(eventType) {
245
+ return createTriggerBuilder(eventType);
246
+ },
247
+ settled(commandTypes) {
248
+ const reg: SettledRegistration = { type: 'settled', commandTypes };
249
+ registrations.push(reg);
250
+ return createSettledChain(reg);
251
+ },
252
+ build() {
253
+ return { name, registrations };
254
+ },
255
+ };
256
+ }
257
+
258
+ return {
259
+ on(eventType) {
260
+ return createTriggerBuilder(eventType);
261
+ },
262
+ settled(commandTypes) {
263
+ const registration: SettledRegistration = { type: 'settled', commandTypes };
264
+ registrations.push(registration);
265
+ return createSettledChain(registration);
266
+ },
267
+ build() {
268
+ return { name, registrations };
269
+ },
270
+ };
271
+ }
272
+
273
+ type GraphBuilderContext = {
274
+ nodeMap: Map<string, GraphNode>;
275
+ edges: GraphEdge[];
276
+ };
277
+
278
+ function addNode(ctx: GraphBuilderContext, id: string, type: NodeType, label: string): void {
279
+ if (!ctx.nodeMap.has(id)) {
280
+ ctx.nodeMap.set(id, { id, type, label });
281
+ }
282
+ }
283
+
284
+ function processEmitRegistration(ctx: GraphBuilderContext, reg: EmitRegistration): void {
285
+ addNode(ctx, `evt:${reg.eventType}`, 'event', reg.eventType);
286
+ for (const cmd of reg.commands) {
287
+ addNode(ctx, `cmd:${cmd.commandType}`, 'command', cmd.commandType);
288
+ ctx.edges.push({ from: `evt:${reg.eventType}`, to: `cmd:${cmd.commandType}` });
289
+ }
290
+ }
291
+
292
+ function processCustomRegistration(ctx: GraphBuilderContext, reg: CustomHandlerRegistration): void {
293
+ addNode(ctx, `evt:${reg.eventType}`, 'event', reg.eventType);
294
+ addNode(ctx, `handler:${reg.eventType}`, 'command', `${reg.eventType} handler`);
295
+ ctx.edges.push({ from: `evt:${reg.eventType}`, to: `handler:${reg.eventType}` });
296
+ }
297
+
298
+ function processSettledRegistration(ctx: GraphBuilderContext, reg: SettledRegistration): void {
299
+ const settledNodeId = `settled:${reg.commandTypes.join(',')}`;
300
+ addNode(ctx, settledNodeId, 'settled', 'Settled');
301
+ for (const commandType of reg.commandTypes) {
302
+ addNode(ctx, `cmd:${commandType}`, 'command', commandType);
303
+ ctx.edges.push({ from: `cmd:${commandType}`, to: settledNodeId });
304
+ }
305
+ }
306
+
307
+ function processPhasedRegistration(ctx: GraphBuilderContext, reg: PhasedRegistration): void {
308
+ addNode(ctx, `evt:${reg.eventType}`, 'event', reg.eventType);
309
+ const phasedNodeId = `phased:${reg.phases.join(',')}`;
310
+ addNode(ctx, phasedNodeId, 'phased', reg.phases.join(' → '));
311
+ ctx.edges.push({ from: `evt:${reg.eventType}`, to: phasedNodeId });
312
+ }
313
+
314
+ function processAwaitRegistration(ctx: GraphBuilderContext, reg: AwaitRegistration): void {
315
+ addNode(ctx, `evt:${reg.eventType}`, 'event', reg.eventType);
316
+ const awaitNodeId = `await:${reg.keys.join(',')}`;
317
+ addNode(ctx, awaitNodeId, 'await', reg.keys.join(', '));
318
+ ctx.edges.push({ from: `evt:${reg.eventType}`, to: awaitNodeId });
319
+ }
320
+
321
+ export function toGraph(pipeline: PipelineV2): GraphIR {
322
+ const ctx: GraphBuilderContext = {
323
+ nodeMap: new Map<string, GraphNode>(),
324
+ edges: [],
325
+ };
326
+
327
+ for (const reg of pipeline.registrations) {
328
+ switch (reg.type) {
329
+ case 'emit':
330
+ processEmitRegistration(ctx, reg);
331
+ break;
332
+ case 'custom':
333
+ processCustomRegistration(ctx, reg);
334
+ break;
335
+ case 'settled':
336
+ processSettledRegistration(ctx, reg);
337
+ break;
338
+ case 'phased':
339
+ processPhasedRegistration(ctx, reg);
340
+ break;
341
+ case 'await':
342
+ processAwaitRegistration(ctx, reg);
343
+ break;
344
+ }
345
+ }
346
+
347
+ return {
348
+ nodes: Array.from(ctx.nodeMap.values()),
349
+ edges: ctx.edges,
350
+ };
351
+ }
@@ -0,0 +1,62 @@
1
+ import { createCommandDispatcher, dispatchAndStore } from './command-dispatcher.js';
2
+ import { createPipelineStore } from './sqlite-store.js';
3
+
4
+ describe('CommandDispatcher', () => {
5
+ it('calls registered handler for matching command type', async () => {
6
+ const dispatcher = createCommandDispatcher();
7
+ const calls: Array<{ type: string; data: Record<string, unknown> }> = [];
8
+
9
+ dispatcher.register('CheckTests', (cmd) => {
10
+ calls.push(cmd);
11
+ return [{ type: 'CheckTestsPassed', data: { duration: 100 } }];
12
+ });
13
+
14
+ const result = await dispatcher.dispatch({ type: 'CheckTests', data: { target: './src' } });
15
+
16
+ expect(calls).toEqual([{ type: 'CheckTests', data: { target: './src' } }]);
17
+ expect(result).toEqual([{ type: 'CheckTestsPassed', data: { duration: 100 } }]);
18
+ });
19
+
20
+ it('throws for unregistered command type', async () => {
21
+ const dispatcher = createCommandDispatcher();
22
+
23
+ await expect(dispatcher.dispatch({ type: 'Unknown', data: {} })).rejects.toThrow(
24
+ 'No handler registered for command type: Unknown',
25
+ );
26
+ });
27
+
28
+ it('returns handler result events', async () => {
29
+ const dispatcher = createCommandDispatcher();
30
+ dispatcher.register('BuildProject', () => [
31
+ { type: 'BuildStarted', data: {} },
32
+ { type: 'BuildCompleted', data: { output: './dist' } },
33
+ ]);
34
+
35
+ const result = await dispatcher.dispatch({ type: 'BuildProject', data: {} });
36
+
37
+ expect(result).toEqual([
38
+ { type: 'BuildStarted', data: {} },
39
+ { type: 'BuildCompleted', data: { output: './dist' } },
40
+ ]);
41
+ });
42
+ });
43
+
44
+ describe('dispatchAndStore', () => {
45
+ it('dispatches command and stores result events', async () => {
46
+ const store = await createPipelineStore();
47
+ const dispatcher = createCommandDispatcher();
48
+ dispatcher.register('CheckTests', () => [{ type: 'CheckTestsPassed', data: { duration: 100 } }]);
49
+
50
+ const results = await dispatchAndStore(dispatcher, store.eventStore, 'pipeline-c1', {
51
+ type: 'CheckTests',
52
+ data: { target: './src' },
53
+ });
54
+
55
+ expect(results).toEqual([{ type: 'CheckTestsPassed', data: { duration: 100 } }]);
56
+
57
+ const stream = await store.eventStore.readStream('pipeline-c1');
58
+ expect(stream.events.map((e) => ({ type: e.type, data: e.data }))).toEqual([
59
+ { type: 'CheckTestsPassed', data: { duration: 100 } },
60
+ ]);
61
+ });
62
+ });