@auto-engineer/pipeline 0.0.1 → 0.15.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 (201) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +279 -0
  4. package/dist/src/builder/define.d.ts +6 -2
  5. package/dist/src/builder/define.d.ts.map +1 -1
  6. package/dist/src/builder/define.js +17 -7
  7. package/dist/src/builder/define.js.map +1 -1
  8. package/dist/src/builder/define.specs.js +3 -3
  9. package/dist/src/builder/define.specs.js.map +1 -1
  10. package/dist/src/core/descriptors.d.ts +6 -2
  11. package/dist/src/core/descriptors.d.ts.map +1 -1
  12. package/dist/src/graph/filter-graph.d.ts +3 -0
  13. package/dist/src/graph/filter-graph.d.ts.map +1 -0
  14. package/dist/src/graph/filter-graph.js +80 -0
  15. package/dist/src/graph/filter-graph.js.map +1 -0
  16. package/dist/src/graph/filter-graph.specs.d.ts +2 -0
  17. package/dist/src/graph/filter-graph.specs.d.ts.map +1 -0
  18. package/dist/src/graph/filter-graph.specs.js +204 -0
  19. package/dist/src/graph/filter-graph.specs.js.map +1 -0
  20. package/dist/src/graph/types.d.ts +8 -0
  21. package/dist/src/graph/types.d.ts.map +1 -1
  22. package/dist/src/index.d.ts +1 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/projections/await-tracker-projection.d.ts +31 -0
  26. package/dist/src/projections/await-tracker-projection.d.ts.map +1 -0
  27. package/dist/src/projections/await-tracker-projection.js +35 -0
  28. package/dist/src/projections/await-tracker-projection.js.map +1 -0
  29. package/dist/src/projections/index.d.ts +4 -0
  30. package/dist/src/projections/index.d.ts.map +1 -0
  31. package/dist/src/projections/index.js +4 -0
  32. package/dist/src/projections/index.js.map +1 -0
  33. package/dist/src/projections/item-status-projection.d.ts +22 -0
  34. package/dist/src/projections/item-status-projection.d.ts.map +1 -0
  35. package/dist/src/projections/item-status-projection.js +11 -0
  36. package/dist/src/projections/item-status-projection.js.map +1 -0
  37. package/dist/src/projections/item-status-projection.specs.d.ts +2 -0
  38. package/dist/src/projections/item-status-projection.specs.d.ts.map +1 -0
  39. package/dist/src/projections/item-status-projection.specs.js +119 -0
  40. package/dist/src/projections/item-status-projection.specs.js.map +1 -0
  41. package/dist/src/projections/latest-run-projection.d.ts +15 -0
  42. package/dist/src/projections/latest-run-projection.d.ts.map +1 -0
  43. package/dist/src/projections/latest-run-projection.js +7 -0
  44. package/dist/src/projections/latest-run-projection.js.map +1 -0
  45. package/dist/src/projections/latest-run-projection.specs.d.ts +2 -0
  46. package/dist/src/projections/latest-run-projection.specs.d.ts.map +1 -0
  47. package/dist/src/projections/latest-run-projection.specs.js +33 -0
  48. package/dist/src/projections/latest-run-projection.specs.js.map +1 -0
  49. package/dist/src/projections/message-log-projection.d.ts +51 -0
  50. package/dist/src/projections/message-log-projection.d.ts.map +1 -0
  51. package/dist/src/projections/message-log-projection.js +51 -0
  52. package/dist/src/projections/message-log-projection.js.map +1 -0
  53. package/dist/src/projections/message-log-projection.specs.d.ts +2 -0
  54. package/dist/src/projections/message-log-projection.specs.d.ts.map +1 -0
  55. package/dist/src/projections/message-log-projection.specs.js +101 -0
  56. package/dist/src/projections/message-log-projection.specs.js.map +1 -0
  57. package/dist/src/projections/node-status-projection.d.ts +23 -0
  58. package/dist/src/projections/node-status-projection.d.ts.map +1 -0
  59. package/dist/src/projections/node-status-projection.js +10 -0
  60. package/dist/src/projections/node-status-projection.js.map +1 -0
  61. package/dist/src/projections/node-status-projection.specs.d.ts +2 -0
  62. package/dist/src/projections/node-status-projection.specs.d.ts.map +1 -0
  63. package/dist/src/projections/node-status-projection.specs.js +116 -0
  64. package/dist/src/projections/node-status-projection.specs.js.map +1 -0
  65. package/dist/src/projections/phased-execution-projection.d.ts +77 -0
  66. package/dist/src/projections/phased-execution-projection.d.ts.map +1 -0
  67. package/dist/src/projections/phased-execution-projection.js +54 -0
  68. package/dist/src/projections/phased-execution-projection.js.map +1 -0
  69. package/dist/src/projections/phased-execution-projection.specs.d.ts +2 -0
  70. package/dist/src/projections/phased-execution-projection.specs.d.ts.map +1 -0
  71. package/dist/src/projections/phased-execution-projection.specs.js +171 -0
  72. package/dist/src/projections/phased-execution-projection.specs.js.map +1 -0
  73. package/dist/src/projections/settled-instance-projection.d.ts +67 -0
  74. package/dist/src/projections/settled-instance-projection.d.ts.map +1 -0
  75. package/dist/src/projections/settled-instance-projection.js +66 -0
  76. package/dist/src/projections/settled-instance-projection.js.map +1 -0
  77. package/dist/src/projections/settled-instance-projection.specs.d.ts +2 -0
  78. package/dist/src/projections/settled-instance-projection.specs.d.ts.map +1 -0
  79. package/dist/src/projections/settled-instance-projection.specs.js +217 -0
  80. package/dist/src/projections/settled-instance-projection.specs.js.map +1 -0
  81. package/dist/src/projections/stats-projection.d.ts +9 -0
  82. package/dist/src/projections/stats-projection.d.ts.map +1 -0
  83. package/dist/src/projections/stats-projection.js +16 -0
  84. package/dist/src/projections/stats-projection.js.map +1 -0
  85. package/dist/src/projections/stats-projection.specs.d.ts +2 -0
  86. package/dist/src/projections/stats-projection.specs.d.ts.map +1 -0
  87. package/dist/src/projections/stats-projection.specs.js +91 -0
  88. package/dist/src/projections/stats-projection.specs.js.map +1 -0
  89. package/dist/src/runtime/await-tracker.d.ts +17 -7
  90. package/dist/src/runtime/await-tracker.d.ts.map +1 -1
  91. package/dist/src/runtime/await-tracker.js +32 -29
  92. package/dist/src/runtime/await-tracker.js.map +1 -1
  93. package/dist/src/runtime/await-tracker.specs.js +56 -38
  94. package/dist/src/runtime/await-tracker.specs.js.map +1 -1
  95. package/dist/src/runtime/context.d.ts +1 -1
  96. package/dist/src/runtime/context.d.ts.map +1 -1
  97. package/dist/src/runtime/event-command-map.d.ts +3 -3
  98. package/dist/src/runtime/event-command-map.d.ts.map +1 -1
  99. package/dist/src/runtime/event-command-map.js +6 -2
  100. package/dist/src/runtime/event-command-map.js.map +1 -1
  101. package/dist/src/runtime/phased-executor.d.ts +15 -9
  102. package/dist/src/runtime/phased-executor.d.ts.map +1 -1
  103. package/dist/src/runtime/phased-executor.js +126 -104
  104. package/dist/src/runtime/phased-executor.js.map +1 -1
  105. package/dist/src/runtime/phased-executor.specs.js +243 -81
  106. package/dist/src/runtime/phased-executor.specs.js.map +1 -1
  107. package/dist/src/runtime/pipeline-runtime.d.ts.map +1 -1
  108. package/dist/src/runtime/pipeline-runtime.js +2 -2
  109. package/dist/src/runtime/pipeline-runtime.js.map +1 -1
  110. package/dist/src/runtime/pipeline-runtime.specs.js +35 -0
  111. package/dist/src/runtime/pipeline-runtime.specs.js.map +1 -1
  112. package/dist/src/runtime/settled-tracker.d.ts +12 -9
  113. package/dist/src/runtime/settled-tracker.d.ts.map +1 -1
  114. package/dist/src/runtime/settled-tracker.js +92 -77
  115. package/dist/src/runtime/settled-tracker.js.map +1 -1
  116. package/dist/src/runtime/settled-tracker.specs.js +568 -118
  117. package/dist/src/runtime/settled-tracker.specs.js.map +1 -1
  118. package/dist/src/server/pipeline-server.d.ts +31 -9
  119. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  120. package/dist/src/server/pipeline-server.e2e.specs.js +2 -10
  121. package/dist/src/server/pipeline-server.e2e.specs.js.map +1 -1
  122. package/dist/src/server/pipeline-server.js +418 -134
  123. package/dist/src/server/pipeline-server.js.map +1 -1
  124. package/dist/src/server/pipeline-server.specs.js +777 -32
  125. package/dist/src/server/pipeline-server.specs.js.map +1 -1
  126. package/dist/src/server/sse-manager.specs.js +55 -35
  127. package/dist/src/server/sse-manager.specs.js.map +1 -1
  128. package/dist/src/store/index.d.ts +3 -0
  129. package/dist/src/store/index.d.ts.map +1 -0
  130. package/dist/src/store/index.js +3 -0
  131. package/dist/src/store/index.js.map +1 -0
  132. package/dist/src/store/pipeline-event-store.d.ts +10 -0
  133. package/dist/src/store/pipeline-event-store.d.ts.map +1 -0
  134. package/dist/src/store/pipeline-event-store.js +112 -0
  135. package/dist/src/store/pipeline-event-store.js.map +1 -0
  136. package/dist/src/store/pipeline-event-store.specs.d.ts +2 -0
  137. package/dist/src/store/pipeline-event-store.specs.d.ts.map +1 -0
  138. package/dist/src/store/pipeline-event-store.specs.js +287 -0
  139. package/dist/src/store/pipeline-event-store.specs.js.map +1 -0
  140. package/dist/src/store/pipeline-read-model.d.ts +49 -0
  141. package/dist/src/store/pipeline-read-model.d.ts.map +1 -0
  142. package/dist/src/store/pipeline-read-model.js +157 -0
  143. package/dist/src/store/pipeline-read-model.js.map +1 -0
  144. package/dist/src/store/pipeline-read-model.specs.d.ts +2 -0
  145. package/dist/src/store/pipeline-read-model.specs.d.ts.map +1 -0
  146. package/dist/src/store/pipeline-read-model.specs.js +830 -0
  147. package/dist/src/store/pipeline-read-model.specs.js.map +1 -0
  148. package/dist/src/testing/fixtures/kanban-full.pipeline.js +2 -2
  149. package/dist/src/testing/fixtures/kanban-full.pipeline.js.map +1 -1
  150. package/dist/src/testing/fixtures/kanban.pipeline.js +2 -2
  151. package/dist/src/testing/fixtures/kanban.pipeline.js.map +1 -1
  152. package/dist/tsconfig.tsbuildinfo +1 -1
  153. package/ketchup-plan.md +960 -0
  154. package/package.json +7 -3
  155. package/src/builder/define.specs.ts +3 -3
  156. package/src/builder/define.ts +24 -11
  157. package/src/core/descriptors.ts +7 -2
  158. package/src/graph/filter-graph.specs.ts +241 -0
  159. package/src/graph/filter-graph.ts +111 -0
  160. package/src/graph/types.ts +10 -0
  161. package/src/index.ts +1 -2
  162. package/src/projections/await-tracker-projection.ts +68 -0
  163. package/src/projections/index.ts +11 -0
  164. package/src/projections/item-status-projection.specs.ts +130 -0
  165. package/src/projections/item-status-projection.ts +32 -0
  166. package/src/projections/latest-run-projection.specs.ts +38 -0
  167. package/src/projections/latest-run-projection.ts +20 -0
  168. package/src/projections/message-log-projection.specs.ts +118 -0
  169. package/src/projections/message-log-projection.ts +113 -0
  170. package/src/projections/node-status-projection.specs.ts +127 -0
  171. package/src/projections/node-status-projection.ts +33 -0
  172. package/src/projections/phased-execution-projection.specs.ts +202 -0
  173. package/src/projections/phased-execution-projection.ts +146 -0
  174. package/src/projections/settled-instance-projection.specs.ts +249 -0
  175. package/src/projections/settled-instance-projection.ts +160 -0
  176. package/src/projections/stats-projection.specs.ts +105 -0
  177. package/src/projections/stats-projection.ts +26 -0
  178. package/src/runtime/await-tracker.specs.ts +57 -34
  179. package/src/runtime/await-tracker.ts +43 -31
  180. package/src/runtime/context.ts +1 -1
  181. package/src/runtime/event-command-map.ts +11 -4
  182. package/src/runtime/phased-executor.specs.ts +357 -81
  183. package/src/runtime/phased-executor.ts +142 -126
  184. package/src/runtime/pipeline-runtime.specs.ts +42 -0
  185. package/src/runtime/pipeline-runtime.ts +6 -4
  186. package/src/runtime/settled-tracker.specs.ts +716 -120
  187. package/src/runtime/settled-tracker.ts +104 -98
  188. package/src/server/pipeline-server.e2e.specs.ts +10 -16
  189. package/src/server/pipeline-server.specs.ts +964 -49
  190. package/src/server/pipeline-server.ts +522 -156
  191. package/src/server/sse-manager.specs.ts +67 -36
  192. package/src/store/index.ts +2 -0
  193. package/src/store/pipeline-event-store.specs.ts +309 -0
  194. package/src/store/pipeline-event-store.ts +156 -0
  195. package/src/store/pipeline-read-model.specs.ts +967 -0
  196. package/src/store/pipeline-read-model.ts +223 -0
  197. package/src/testing/fixtures/kanban-full.pipeline.ts +2 -2
  198. package/src/testing/fixtures/kanban.pipeline.ts +2 -2
  199. package/claude.md +0 -160
  200. package/docs/testing-analysis.md +0 -395
  201. package/pomodoro-plan.md +0 -651
@@ -104,44 +104,392 @@ describe('PipelineServer', () => {
104
104
  expect(data.nodes.some((n) => n.id === 'evt:Start')).toBe(true);
105
105
  await server.stop();
106
106
  });
107
- it('should return pipeline response with commandToEvents', async () => {
107
+ it('should use displayName as label for command graph nodes', async () => {
108
108
  const handler = {
109
- name: 'Gen',
110
- events: ['GenDone', 'GenProgress'],
111
- handle: async () => ({ type: 'GenDone', data: {} }),
109
+ name: 'Cmd',
110
+ displayName: 'My Command',
111
+ handle: async () => ({ type: 'Done', data: {} }),
112
+ };
113
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
114
+ const server = new PipelineServer({ port: 0 });
115
+ server.registerCommandHandlers([handler]);
116
+ server.registerPipeline(pipeline);
117
+ await server.start();
118
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
119
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:Cmd');
120
+ expect(cmdNode?.label).toBe('My Command');
121
+ await server.stop();
122
+ });
123
+ it('should use command name as graph node label when displayName not provided', async () => {
124
+ const handler = {
125
+ name: 'SimpleCmd',
126
+ handle: async () => ({ type: 'Done', data: {} }),
112
127
  };
128
+ const pipeline = define('test').on('Start').emit('SimpleCmd', {}).build();
113
129
  const server = new PipelineServer({ port: 0 });
114
130
  server.registerCommandHandlers([handler]);
131
+ server.registerPipeline(pipeline);
115
132
  await server.start();
116
133
  const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
117
- expect(data.commandToEvents).toEqual({ Gen: ['GenDone', 'GenProgress'] });
134
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SimpleCmd');
135
+ expect(cmdNode?.label).toBe('SimpleCmd');
118
136
  await server.stop();
119
137
  });
120
- it('should return pipeline response with eventToCommand', async () => {
138
+ it('should filter out event nodes when excludeTypes=event', async () => {
139
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
140
+ const server = new PipelineServer({ port: 0 });
141
+ server.registerPipeline(pipeline);
142
+ await server.start();
143
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?excludeTypes=event`);
144
+ expect(data.nodes.every((n) => n.type !== 'event')).toBe(true);
145
+ await server.stop();
146
+ });
147
+ it('should reconnect edges when maintainEdges=true and filter commands', async () => {
121
148
  const pipeline = define('test').on('Start').emit('Process', {}).build();
122
149
  const server = new PipelineServer({ port: 0 });
123
150
  server.registerPipeline(pipeline);
124
151
  await server.start();
152
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?excludeTypes=command&maintainEdges=true`);
153
+ expect(data.nodes.every((n) => n.type !== 'command')).toBe(true);
154
+ expect(data.edges).toHaveLength(0);
155
+ await server.stop();
156
+ });
157
+ it('should filter multiple node types', async () => {
158
+ const pipeline = define('test')
159
+ .on('Start')
160
+ .emit('CheckA', {})
161
+ .emit('CheckB', {})
162
+ .settled(['CheckA', 'CheckB'])
163
+ .dispatch({ dispatches: [] }, () => { })
164
+ .build();
165
+ const server = new PipelineServer({ port: 0 });
166
+ server.registerPipeline(pipeline);
167
+ await server.start();
168
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?excludeTypes=event,settled`);
169
+ expect(data.nodes.every((n) => n.type !== 'event' && n.type !== 'settled')).toBe(true);
170
+ await server.stop();
171
+ });
172
+ it('should reconnect commands through events when filtering events with maintainEdges=true', async () => {
173
+ const generateHandler = {
174
+ name: 'Generate',
175
+ events: ['Generated'],
176
+ handle: async () => ({ type: 'Generated', data: {} }),
177
+ };
178
+ const pipeline = define('test').on('Start').emit('Generate', {}).on('Generated').emit('Process', {}).build();
179
+ const server = new PipelineServer({ port: 0 });
180
+ server.registerCommandHandlers([generateHandler]);
181
+ server.registerPipeline(pipeline);
182
+ await server.start();
183
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?excludeTypes=event&maintainEdges=true`);
184
+ expect(data.nodes.every((n) => n.type !== 'event')).toBe(true);
185
+ expect(data.edges.some((e) => e.from === 'cmd:Generate' && e.to === 'cmd:Process')).toBe(true);
186
+ await server.stop();
187
+ });
188
+ it('should have status idle on command nodes by default', async () => {
189
+ const handler = {
190
+ name: 'Cmd',
191
+ events: ['Done'],
192
+ handle: async () => ({ type: 'Done', data: {} }),
193
+ };
194
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
195
+ const server = new PipelineServer({ port: 0 });
196
+ server.registerCommandHandlers([handler]);
197
+ server.registerPipeline(pipeline);
198
+ await server.start();
125
199
  const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
126
- expect(data.eventToCommand).toEqual({ Start: 'Process' });
200
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:Cmd');
201
+ expect(cmdNode?.status).toBe('idle');
127
202
  await server.stop();
128
203
  });
129
- it('should return pipeline nodes with name, title, and status', async () => {
204
+ it('should not have status on event nodes', async () => {
130
205
  const handler = {
131
206
  name: 'Cmd',
132
- alias: 'cmd',
133
- description: 'Test command',
207
+ events: ['Done'],
134
208
  handle: async () => ({ type: 'Done', data: {} }),
135
209
  };
210
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
136
211
  const server = new PipelineServer({ port: 0 });
137
212
  server.registerCommandHandlers([handler]);
213
+ server.registerPipeline(pipeline);
138
214
  await server.start();
139
215
  const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
140
- const cmdNode = data.nodes.find((n) => n.id === 'Cmd');
141
- expect(cmdNode).toBeDefined();
142
- expect(cmdNode?.name).toBe('Cmd');
143
- expect(cmdNode?.title).toBe('Test command');
144
- expect(cmdNode?.status).toBe('None');
216
+ const eventNode = data.nodes.find((n) => n.id === 'evt:Start');
217
+ expect(eventNode?.status).toBeUndefined();
218
+ await server.stop();
219
+ });
220
+ it('should have idle status on settled nodes when no correlationId provided', async () => {
221
+ const handler = {
222
+ name: 'CheckTests',
223
+ events: ['TestsPassed'],
224
+ handle: async () => ({ type: 'TestsPassed', data: {} }),
225
+ };
226
+ const pipeline = define('test')
227
+ .on('Start')
228
+ .emit('CheckTests', {})
229
+ .settled(['CheckTests'])
230
+ .dispatch({ dispatches: [] }, () => { })
231
+ .build();
232
+ const server = new PipelineServer({ port: 0 });
233
+ server.registerCommandHandlers([handler]);
234
+ server.registerPipeline(pipeline);
235
+ await server.start();
236
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
237
+ const settledNode = data.nodes.find((n) => n.id === 'settled:CheckTests');
238
+ expect(settledNode?.status).toBe('idle');
239
+ expect(settledNode?.pendingCount).toBe(0);
240
+ expect(settledNode?.endedCount).toBe(0);
241
+ await server.stop();
242
+ });
243
+ it('should show running status for command being executed', async () => {
244
+ let resolveHandler = () => { };
245
+ const handlerPromise = new Promise((resolve) => {
246
+ resolveHandler = resolve;
247
+ });
248
+ const handler = {
249
+ name: 'SlowCmd',
250
+ events: ['Done'],
251
+ handle: async () => {
252
+ await handlerPromise;
253
+ return { type: 'Done', data: {} };
254
+ },
255
+ };
256
+ const pipeline = define('test').on('Start').emit('SlowCmd', {}).build();
257
+ const server = new PipelineServer({ port: 0 });
258
+ server.registerCommandHandlers([handler]);
259
+ server.registerPipeline(pipeline);
260
+ await server.start();
261
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({ type: 'SlowCmd', data: {} }),
265
+ });
266
+ await new Promise((r) => setTimeout(r, 50));
267
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
268
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SlowCmd');
269
+ expect(cmdNode?.status).toBe('running');
270
+ resolveHandler();
271
+ await server.stop();
272
+ });
273
+ it('should show success status after command completes with success event', async () => {
274
+ const handler = {
275
+ name: 'SuccessCmd',
276
+ events: ['CmdCompleted'],
277
+ handle: async () => ({ type: 'CmdCompleted', data: {} }),
278
+ };
279
+ const pipeline = define('test').on('Start').emit('SuccessCmd', {}).build();
280
+ const server = new PipelineServer({ port: 0 });
281
+ server.registerCommandHandlers([handler]);
282
+ server.registerPipeline(pipeline);
283
+ await server.start();
284
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({ type: 'SuccessCmd', data: {} }),
288
+ });
289
+ await new Promise((r) => setTimeout(r, 100));
290
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
291
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SuccessCmd');
292
+ expect(cmdNode?.status).toBe('success');
293
+ await server.stop();
294
+ });
295
+ it('should show error status after command completes with failed event', async () => {
296
+ const handler = {
297
+ name: 'FailCmd',
298
+ events: ['CmdFailed'],
299
+ handle: async () => ({ type: 'CmdFailed', data: { error: 'Something went wrong' } }),
300
+ };
301
+ const pipeline = define('test').on('Start').emit('FailCmd', {}).build();
302
+ const server = new PipelineServer({ port: 0 });
303
+ server.registerCommandHandlers([handler]);
304
+ server.registerPipeline(pipeline);
305
+ await server.start();
306
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify({ type: 'FailCmd', data: {} }),
310
+ });
311
+ await new Promise((r) => setTimeout(r, 100));
312
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
313
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:FailCmd');
314
+ expect(cmdNode?.status).toBe('error');
315
+ await server.stop();
316
+ });
317
+ it('should broadcast PipelineRunStarted event when new correlationId is first seen', async () => {
318
+ const handler = {
319
+ name: 'StartCmd',
320
+ events: ['Started'],
321
+ handle: async () => ({ type: 'Started', data: {} }),
322
+ };
323
+ const pipeline = define('test').on('Trigger').emit('StartCmd', {}).build();
324
+ const server = new PipelineServer({ port: 0 });
325
+ server.registerCommandHandlers([handler]);
326
+ server.registerPipeline(pipeline);
327
+ await server.start();
328
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({ type: 'StartCmd', data: {} }),
332
+ });
333
+ await new Promise((r) => setTimeout(r, 100));
334
+ const msgs = await fetchAs(`http://localhost:${server.port}/messages`);
335
+ const pipelineRunStarted = msgs.find((m) => m.message.type === 'PipelineRunStarted');
336
+ expect(pipelineRunStarted).toBeDefined();
337
+ expect((pipelineRunStarted?.message).correlationId).toBe(commandResponse.correlationId);
338
+ expect((pipelineRunStarted?.message).data?.triggerCommand).toBe('StartCmd');
339
+ await server.stop();
340
+ });
341
+ it('should broadcast NodeStatusChanged event when command starts running', async () => {
342
+ const handler = {
343
+ name: 'RunCmd',
344
+ events: ['RunDone'],
345
+ handle: async () => ({ type: 'RunDone', data: {} }),
346
+ };
347
+ const pipeline = define('test').on('Trigger').emit('RunCmd', {}).build();
348
+ const server = new PipelineServer({ port: 0 });
349
+ server.registerCommandHandlers([handler]);
350
+ server.registerPipeline(pipeline);
351
+ await server.start();
352
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
353
+ method: 'POST',
354
+ headers: { 'Content-Type': 'application/json' },
355
+ body: JSON.stringify({ type: 'RunCmd', data: {} }),
356
+ });
357
+ await new Promise((r) => setTimeout(r, 100));
358
+ const msgs = await fetchAs(`http://localhost:${server.port}/messages`);
359
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
360
+ const runningEvent = nodeStatusChanged.find((m) => m.message.data?.status === 'running');
361
+ expect(runningEvent).toBeDefined();
362
+ expect((runningEvent?.message).data?.nodeId).toBe('cmd:RunCmd');
363
+ expect((runningEvent?.message).data?.previousStatus).toBe('idle');
364
+ expect((runningEvent?.message).correlationId).toBe(commandResponse.correlationId);
365
+ await server.stop();
366
+ });
367
+ it('should broadcast NodeStatusChanged event when command completes', async () => {
368
+ const handler = {
369
+ name: 'CompleteCmd',
370
+ events: ['CompleteDone'],
371
+ handle: async () => ({ type: 'CompleteDone', data: {} }),
372
+ };
373
+ const pipeline = define('test').on('Trigger').emit('CompleteCmd', {}).build();
374
+ const server = new PipelineServer({ port: 0 });
375
+ server.registerCommandHandlers([handler]);
376
+ server.registerPipeline(pipeline);
377
+ await server.start();
378
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/json' },
381
+ body: JSON.stringify({ type: 'CompleteCmd', data: {} }),
382
+ });
383
+ await new Promise((r) => setTimeout(r, 100));
384
+ const msgs = await fetchAs(`http://localhost:${server.port}/messages`);
385
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
386
+ const successEvent = nodeStatusChanged.find((m) => m.message.data?.status === 'success');
387
+ expect(successEvent).toBeDefined();
388
+ expect((successEvent?.message).data?.nodeId).toBe('cmd:CompleteCmd');
389
+ expect((successEvent?.message).data?.previousStatus).toBe('running');
390
+ expect((successEvent?.message).correlationId).toBe(commandResponse.correlationId);
391
+ await server.stop();
392
+ });
393
+ it('should persist status across multiple /pipeline calls', async () => {
394
+ const handler = {
395
+ name: 'PersistCmd',
396
+ events: ['PersistDone'],
397
+ handle: async () => ({ type: 'PersistDone', data: {} }),
398
+ };
399
+ const pipeline = define('test').on('Trigger').emit('PersistCmd', {}).build();
400
+ const server = new PipelineServer({ port: 0 });
401
+ server.registerCommandHandlers([handler]);
402
+ server.registerPipeline(pipeline);
403
+ await server.start();
404
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
405
+ method: 'POST',
406
+ headers: { 'Content-Type': 'application/json' },
407
+ body: JSON.stringify({ type: 'PersistCmd', data: {} }),
408
+ });
409
+ await new Promise((r) => setTimeout(r, 100));
410
+ const firstCall = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
411
+ expect(firstCall.nodes.find((n) => n.id === 'cmd:PersistCmd')?.status).toBe('success');
412
+ const secondCall = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
413
+ expect(secondCall.nodes.find((n) => n.id === 'cmd:PersistCmd')?.status).toBe('success');
414
+ await server.stop();
415
+ });
416
+ it('should track status independently for different correlationIds', async () => {
417
+ const handler = {
418
+ name: 'IndependentCmd',
419
+ events: ['IndependentDone'],
420
+ handle: async () => ({ type: 'IndependentDone', data: {} }),
421
+ };
422
+ const pipeline = define('test').on('Trigger').emit('IndependentCmd', {}).build();
423
+ const server = new PipelineServer({ port: 0 });
424
+ server.registerCommandHandlers([handler]);
425
+ server.registerPipeline(pipeline);
426
+ await server.start();
427
+ const run1 = await fetchAs(`http://localhost:${server.port}/command`, {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify({ type: 'IndependentCmd', data: {} }),
431
+ });
432
+ const run2 = await fetchAs(`http://localhost:${server.port}/command`, {
433
+ method: 'POST',
434
+ headers: { 'Content-Type': 'application/json' },
435
+ body: JSON.stringify({ type: 'IndependentCmd', data: {} }),
436
+ });
437
+ await new Promise((r) => setTimeout(r, 100));
438
+ expect(run1.correlationId).not.toBe(run2.correlationId);
439
+ const pipeline1 = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${run1.correlationId}`);
440
+ const pipeline2 = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${run2.correlationId}`);
441
+ expect(pipeline1.nodes.find((n) => n.id === 'cmd:IndependentCmd')?.status).toBe('success');
442
+ expect(pipeline2.nodes.find((n) => n.id === 'cmd:IndependentCmd')?.status).toBe('success');
443
+ await server.stop();
444
+ });
445
+ it('should show idle status for all command nodes when no correlationId provided', async () => {
446
+ const handler = {
447
+ name: 'IdleCmd',
448
+ events: ['IdleDone'],
449
+ handle: async () => ({ type: 'IdleDone', data: {} }),
450
+ };
451
+ const pipeline = define('test').on('Trigger').emit('IdleCmd', {}).build();
452
+ const server = new PipelineServer({ port: 0 });
453
+ server.registerCommandHandlers([handler]);
454
+ server.registerPipeline(pipeline);
455
+ await server.start();
456
+ await fetchAs(`http://localhost:${server.port}/command`, {
457
+ method: 'POST',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ type: 'IdleCmd', data: {} }),
460
+ });
461
+ await new Promise((r) => setTimeout(r, 100));
462
+ const pipelineWithoutCorrelation = await fetchAs(`http://localhost:${server.port}/pipeline`);
463
+ const cmdNode = pipelineWithoutCorrelation.nodes.find((n) => n.id === 'cmd:IdleCmd');
464
+ expect(cmdNode?.status).toBe('idle');
465
+ await server.stop();
466
+ });
467
+ it('should return latestRun with the most recent correlationId', async () => {
468
+ const handler = {
469
+ name: 'LatestCmd',
470
+ events: ['LatestDone'],
471
+ handle: async () => ({ type: 'LatestDone', data: {} }),
472
+ };
473
+ const pipeline = define('test').on('Trigger').emit('LatestCmd', {}).build();
474
+ const server = new PipelineServer({ port: 0 });
475
+ server.registerCommandHandlers([handler]);
476
+ server.registerPipeline(pipeline);
477
+ await server.start();
478
+ const run1 = await fetchAs(`http://localhost:${server.port}/command`, {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ type: 'LatestCmd', data: {} }),
482
+ });
483
+ await new Promise((r) => setTimeout(r, 50));
484
+ const run2 = await fetchAs(`http://localhost:${server.port}/command`, {
485
+ method: 'POST',
486
+ headers: { 'Content-Type': 'application/json' },
487
+ body: JSON.stringify({ type: 'LatestCmd', data: {} }),
488
+ });
489
+ await new Promise((r) => setTimeout(r, 50));
490
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
491
+ expect(data.latestRun).toBe(run2.correlationId);
492
+ expect(data.latestRun).not.toBe(run1.correlationId);
145
493
  await server.stop();
146
494
  });
147
495
  });
@@ -216,15 +564,6 @@ describe('PipelineServer', () => {
216
564
  await server.stop();
217
565
  });
218
566
  });
219
- describe('GET /sessions', () => {
220
- it('should return sessions', async () => {
221
- const server = new PipelineServer({ port: 0 });
222
- await server.start();
223
- const data = await fetchAs(`http://localhost:${server.port}/sessions`);
224
- expect(Array.isArray(data)).toBe(true);
225
- await server.stop();
226
- });
227
- });
228
567
  describe('event routing', () => {
229
568
  it('should route events through pipeline', async () => {
230
569
  const handler = {
@@ -284,6 +623,55 @@ describe('PipelineServer', () => {
284
623
  expect(mermaid).toContain('flowchart LR');
285
624
  await server.stop();
286
625
  });
626
+ it('should filter out event nodes when excludeTypes=event', async () => {
627
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
628
+ const server = new PipelineServer({ port: 0 });
629
+ server.registerPipeline(pipeline);
630
+ await server.start();
631
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid?excludeTypes=event`);
632
+ const mermaid = await res.text();
633
+ expect(mermaid).not.toContain('evt_Start');
634
+ expect(mermaid).toContain('Process[Process]');
635
+ await server.stop();
636
+ });
637
+ it('should filter out settled nodes when excludeTypes=settled', async () => {
638
+ const checkAHandler = {
639
+ name: 'CheckA',
640
+ events: ['CheckAPassed', 'CheckAFailed'],
641
+ handle: async () => ({ type: 'CheckAPassed', data: {} }),
642
+ };
643
+ const pipeline = define('test')
644
+ .on('Start')
645
+ .emit('CheckA', {})
646
+ .settled(['CheckA'])
647
+ .dispatch({ dispatches: [] }, () => { })
648
+ .build();
649
+ const server = new PipelineServer({ port: 0 });
650
+ server.registerCommandHandlers([checkAHandler]);
651
+ server.registerPipeline(pipeline);
652
+ await server.start();
653
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid?excludeTypes=settled`);
654
+ const mermaid = await res.text();
655
+ expect(mermaid).not.toContain('settled_');
656
+ expect(mermaid).toContain('CheckA');
657
+ await server.stop();
658
+ });
659
+ it('should use displayName as label for command nodes in mermaid diagram', async () => {
660
+ const handler = {
661
+ name: 'Cmd',
662
+ displayName: 'My Command',
663
+ handle: async () => ({ type: 'Done', data: {} }),
664
+ };
665
+ const pipeline = define('test').on('Start').emit('Cmd', {}).build();
666
+ const server = new PipelineServer({ port: 0 });
667
+ server.registerCommandHandlers([handler]);
668
+ server.registerPipeline(pipeline);
669
+ await server.start();
670
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
671
+ const mermaid = await res.text();
672
+ expect(mermaid).toContain('Cmd[My Command]');
673
+ await server.stop();
674
+ });
287
675
  it('should include event nodes in mermaid diagram', async () => {
288
676
  const pipeline = define('test').on('Start').emit('Process', {}).build();
289
677
  const server = new PipelineServer({ port: 0 });
@@ -294,6 +682,26 @@ describe('PipelineServer', () => {
294
682
  expect(mermaid).toContain('evt_Start');
295
683
  await server.stop();
296
684
  });
685
+ it('should use displayName as label for event nodes in mermaid diagram', async () => {
686
+ const handler = {
687
+ name: 'Cmd',
688
+ events: [{ name: 'CmdCompleted', displayName: 'Command Completed' }],
689
+ handle: async () => ({ type: 'CmdCompleted', data: {} }),
690
+ };
691
+ const nextHandler = {
692
+ name: 'NextCmd',
693
+ handle: async () => ({ type: 'Done', data: {} }),
694
+ };
695
+ const pipeline = define('test').on('Start').emit('Cmd', {}).on('CmdCompleted').emit('NextCmd', {}).build();
696
+ const server = new PipelineServer({ port: 0 });
697
+ server.registerCommandHandlers([handler, nextHandler]);
698
+ server.registerPipeline(pipeline);
699
+ await server.start();
700
+ const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
701
+ const mermaid = await res.text();
702
+ expect(mermaid).toContain('evt_CmdCompleted([Command Completed])');
703
+ await server.stop();
704
+ });
297
705
  it('should include command nodes in mermaid diagram', async () => {
298
706
  const pipeline = define('test').on('Start').emit('Process', {}).build();
299
707
  const server = new PipelineServer({ port: 0 });
@@ -468,7 +876,7 @@ describe('PipelineServer', () => {
468
876
  expect(mermaid).not.toContain('ServerStartFailed');
469
877
  await server.stop();
470
878
  });
471
- it('should show edges from command events to settled node, not from commands', async () => {
879
+ it('should show edges from commands to settled node', async () => {
472
880
  const checkAHandler = {
473
881
  name: 'CheckA',
474
882
  events: ['CheckAPassed', 'CheckAFailed'],
@@ -492,12 +900,8 @@ describe('PipelineServer', () => {
492
900
  await server.start();
493
901
  const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
494
902
  const mermaid = await res.text();
495
- expect(mermaid).toContain('evt_CheckAPassed --> settled_CheckA_CheckB');
496
- expect(mermaid).toContain('evt_CheckAFailed --> settled_CheckA_CheckB');
497
- expect(mermaid).toContain('evt_CheckBPassed --> settled_CheckA_CheckB');
498
- expect(mermaid).toContain('evt_CheckBFailed --> settled_CheckA_CheckB');
499
- expect(mermaid).not.toMatch(/CheckA --> settled_/);
500
- expect(mermaid).not.toMatch(/CheckB --> settled_/);
903
+ expect(mermaid).toContain('CheckA --> settled_CheckA_CheckB');
904
+ expect(mermaid).toContain('CheckB --> settled_CheckA_CheckB');
501
905
  await server.stop();
502
906
  });
503
907
  it('should show edges from settled node to dispatched commands', async () => {
@@ -523,7 +927,7 @@ describe('PipelineServer', () => {
523
927
  await server.start();
524
928
  const res = await fetch(`http://localhost:${server.port}/pipeline/mermaid`);
525
929
  const mermaid = await res.text();
526
- expect(mermaid).toContain('settled_CheckA --> RetryCommand');
930
+ expect(mermaid).toContain('settled_CheckA -.->|retry| RetryCommand');
527
931
  await server.stop();
528
932
  });
529
933
  it('should style backLink edges in red', async () => {
@@ -553,6 +957,57 @@ describe('PipelineServer', () => {
553
957
  expect(mermaid).toMatch(/stroke:#[a-fA-F0-9]{6}|stroke:red/);
554
958
  await server.stop();
555
959
  });
960
+ it('should mark event-to-command edges as backLink when they create cycles', async () => {
961
+ const generateHandler = {
962
+ name: 'GenerateIA',
963
+ events: ['IAGenerated', 'IAValidationFailed'],
964
+ handle: async () => ({ type: 'IAGenerated', data: {} }),
965
+ };
966
+ const pipeline = define('test')
967
+ .on('Start')
968
+ .emit('GenerateIA', {})
969
+ .on('IAValidationFailed')
970
+ .emit('GenerateIA', {})
971
+ .build();
972
+ const server = new PipelineServer({ port: 0 });
973
+ server.registerCommandHandlers([generateHandler]);
974
+ server.registerPipeline(pipeline);
975
+ await server.start();
976
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
977
+ const backLinkEdge = data.edges.find((e) => e.from === 'evt:IAValidationFailed' && e.to === 'cmd:GenerateIA');
978
+ expect(backLinkEdge).toBeDefined();
979
+ expect(backLinkEdge?.backLink).toBe(true);
980
+ await server.stop();
981
+ });
982
+ it('should NOT mark forward edges as backLink when cycle is broken by settled dispatch', async () => {
983
+ const implHandler = {
984
+ name: 'ImplementSlice',
985
+ events: ['SliceImplemented'],
986
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
987
+ };
988
+ const checkHandler = {
989
+ name: 'CheckTests',
990
+ events: ['TestsCheckPassed', 'TestsCheckFailed'],
991
+ handle: async () => ({ type: 'TestsCheckPassed', data: {} }),
992
+ };
993
+ const pipeline = define('test')
994
+ .on('Start')
995
+ .emit('ImplementSlice', {})
996
+ .on('SliceImplemented')
997
+ .emit('CheckTests', {})
998
+ .settled(['CheckTests'])
999
+ .dispatch({ dispatches: ['ImplementSlice'] }, () => { })
1000
+ .build();
1001
+ const server = new PipelineServer({ port: 0 });
1002
+ server.registerCommandHandlers([implHandler, checkHandler]);
1003
+ server.registerPipeline(pipeline);
1004
+ await server.start();
1005
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
1006
+ const forwardEdge = data.edges.find((e) => e.from === 'evt:SliceImplemented' && e.to === 'cmd:CheckTests');
1007
+ expect(forwardEdge).toBeDefined();
1008
+ expect(forwardEdge?.backLink).toBeUndefined();
1009
+ await server.stop();
1010
+ });
556
1011
  it('should add event nodes from settled handler commandToEvents when not already added', async () => {
557
1012
  const checkAHandler = {
558
1013
  name: 'CheckA',
@@ -594,6 +1049,17 @@ describe('PipelineServer', () => {
594
1049
  expect(res.headers.get('content-type')).toContain('text/html');
595
1050
  await server.stop();
596
1051
  });
1052
+ it('should filter nodes when excludeTypes is provided', async () => {
1053
+ const pipeline = define('test').on('Start').emit('Process', {}).build();
1054
+ const server = new PipelineServer({ port: 0 });
1055
+ server.registerPipeline(pipeline);
1056
+ await server.start();
1057
+ const res = await fetch(`http://localhost:${server.port}/pipeline/diagram?excludeTypes=event`);
1058
+ const html = await res.text();
1059
+ expect(html).not.toContain('evt_Start');
1060
+ expect(html).toContain('Process');
1061
+ await server.stop();
1062
+ });
597
1063
  it('should include mermaid.js script', async () => {
598
1064
  const pipeline = define('test').on('Start').emit('Process', {}).build();
599
1065
  const server = new PipelineServer({ port: 0 });
@@ -628,6 +1094,285 @@ describe('PipelineServer', () => {
628
1094
  await server.stop();
629
1095
  });
630
1096
  });
1097
+ describe('item-level tracking', () => {
1098
+ it('should extract itemKey from command data using registered extractor', async () => {
1099
+ const handler = {
1100
+ name: 'ImplementSlice',
1101
+ events: ['SliceImplemented'],
1102
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
1103
+ };
1104
+ const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
1105
+ const server = new PipelineServer({ port: 0 });
1106
+ server.registerCommandHandlers([handler]);
1107
+ server.registerPipeline(pipeline);
1108
+ server.registerItemKeyExtractor('ImplementSlice', (d) => d.slicePath);
1109
+ await server.start();
1110
+ const commandResponse = await fetchAs(`http://localhost:${server.port}/command`, {
1111
+ method: 'POST',
1112
+ headers: { 'Content-Type': 'application/json' },
1113
+ body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-1' } }),
1114
+ });
1115
+ await new Promise((r) => setTimeout(r, 100));
1116
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${commandResponse.correlationId}`);
1117
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:ImplementSlice');
1118
+ expect(cmdNode?.pendingCount).toBe(0);
1119
+ expect(cmdNode?.endedCount).toBe(1);
1120
+ await server.stop();
1121
+ });
1122
+ it('should count multiple parallel items correctly', async () => {
1123
+ const handler = {
1124
+ name: 'ImplementSlice',
1125
+ events: ['SliceImplemented'],
1126
+ handle: async () => ({ type: 'SliceImplemented', data: {} }),
1127
+ };
1128
+ const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
1129
+ const server = new PipelineServer({ port: 0 });
1130
+ server.registerCommandHandlers([handler]);
1131
+ server.registerPipeline(pipeline);
1132
+ server.registerItemKeyExtractor('ImplementSlice', (d) => d.slicePath);
1133
+ await server.start();
1134
+ const correlationId = `corr-parallel-test`;
1135
+ await Promise.all([
1136
+ fetch(`http://localhost:${server.port}/command`, {
1137
+ method: 'POST',
1138
+ headers: { 'Content-Type': 'application/json' },
1139
+ body: JSON.stringify({
1140
+ type: 'ImplementSlice',
1141
+ data: { slicePath: '/server/slice-1' },
1142
+ correlationId,
1143
+ }),
1144
+ }),
1145
+ fetch(`http://localhost:${server.port}/command`, {
1146
+ method: 'POST',
1147
+ headers: { 'Content-Type': 'application/json' },
1148
+ body: JSON.stringify({
1149
+ type: 'ImplementSlice',
1150
+ data: { slicePath: '/server/slice-2' },
1151
+ correlationId,
1152
+ }),
1153
+ }),
1154
+ fetch(`http://localhost:${server.port}/command`, {
1155
+ method: 'POST',
1156
+ headers: { 'Content-Type': 'application/json' },
1157
+ body: JSON.stringify({
1158
+ type: 'ImplementSlice',
1159
+ data: { slicePath: '/server/slice-3' },
1160
+ correlationId,
1161
+ }),
1162
+ }),
1163
+ ]);
1164
+ await new Promise((r) => setTimeout(r, 100));
1165
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1166
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:ImplementSlice');
1167
+ expect(cmdNode?.pendingCount).toBe(0);
1168
+ expect(cmdNode?.endedCount).toBe(3);
1169
+ await server.stop();
1170
+ });
1171
+ it('should show pending count while commands are running', async () => {
1172
+ const resolveHandlers = [];
1173
+ const handler = {
1174
+ name: 'SlowSlice',
1175
+ events: ['SlowSliceDone'],
1176
+ handle: async () => {
1177
+ await new Promise((resolve) => {
1178
+ resolveHandlers.push(resolve);
1179
+ });
1180
+ return { type: 'SlowSliceDone', data: {} };
1181
+ },
1182
+ };
1183
+ const pipeline = define('test').on('Trigger').emit('SlowSlice', {}).build();
1184
+ const server = new PipelineServer({ port: 0 });
1185
+ server.registerCommandHandlers([handler]);
1186
+ server.registerPipeline(pipeline);
1187
+ server.registerItemKeyExtractor('SlowSlice', (d) => d.id);
1188
+ await server.start();
1189
+ const correlationId = `corr-slow-test`;
1190
+ void fetch(`http://localhost:${server.port}/command`, {
1191
+ method: 'POST',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-1' }, correlationId }),
1194
+ });
1195
+ void fetch(`http://localhost:${server.port}/command`, {
1196
+ method: 'POST',
1197
+ headers: { 'Content-Type': 'application/json' },
1198
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-2' }, correlationId }),
1199
+ });
1200
+ void fetch(`http://localhost:${server.port}/command`, {
1201
+ method: 'POST',
1202
+ headers: { 'Content-Type': 'application/json' },
1203
+ body: JSON.stringify({ type: 'SlowSlice', data: { id: 'item-3' }, correlationId }),
1204
+ });
1205
+ await new Promise((r) => setTimeout(r, 50));
1206
+ const midwayData = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1207
+ const midwayNode = midwayData.nodes.find((n) => n.id === 'cmd:SlowSlice');
1208
+ expect(midwayNode?.pendingCount).toBe(3);
1209
+ expect(midwayNode?.endedCount).toBe(0);
1210
+ expect(midwayNode?.status).toBe('running');
1211
+ resolveHandlers.forEach((r) => r());
1212
+ await new Promise((r) => setTimeout(r, 50));
1213
+ const finalData = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1214
+ const finalNode = finalData.nodes.find((n) => n.id === 'cmd:SlowSlice');
1215
+ expect(finalNode?.pendingCount).toBe(0);
1216
+ expect(finalNode?.endedCount).toBe(3);
1217
+ expect(finalNode?.status).toBe('success');
1218
+ await server.stop();
1219
+ });
1220
+ it('should show error status when any item fails', async () => {
1221
+ const handler = {
1222
+ name: 'MixedSlice',
1223
+ events: ['MixedSliceDone', 'MixedSliceFailed'],
1224
+ handle: async (cmd) => {
1225
+ if (cmd.data.shouldFail === true) {
1226
+ return { type: 'MixedSliceFailed', data: {} };
1227
+ }
1228
+ return { type: 'MixedSliceDone', data: {} };
1229
+ },
1230
+ };
1231
+ const pipeline = define('test').on('Trigger').emit('MixedSlice', {}).build();
1232
+ const server = new PipelineServer({ port: 0 });
1233
+ server.registerCommandHandlers([handler]);
1234
+ server.registerPipeline(pipeline);
1235
+ server.registerItemKeyExtractor('MixedSlice', (d) => d.id);
1236
+ await server.start();
1237
+ const correlationId = `corr-mixed-test`;
1238
+ await Promise.all([
1239
+ fetch(`http://localhost:${server.port}/command`, {
1240
+ method: 'POST',
1241
+ headers: { 'Content-Type': 'application/json' },
1242
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-1' }, correlationId }),
1243
+ }),
1244
+ fetch(`http://localhost:${server.port}/command`, {
1245
+ method: 'POST',
1246
+ headers: { 'Content-Type': 'application/json' },
1247
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'fail-1', shouldFail: true }, correlationId }),
1248
+ }),
1249
+ fetch(`http://localhost:${server.port}/command`, {
1250
+ method: 'POST',
1251
+ headers: { 'Content-Type': 'application/json' },
1252
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-2' }, correlationId }),
1253
+ }),
1254
+ ]);
1255
+ await new Promise((r) => setTimeout(r, 100));
1256
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1257
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:MixedSlice');
1258
+ expect(cmdNode?.pendingCount).toBe(0);
1259
+ expect(cmdNode?.endedCount).toBe(3);
1260
+ expect(cmdNode?.status).toBe('error');
1261
+ await server.stop();
1262
+ });
1263
+ it('should reset item to running when retry command arrives for same itemKey', async () => {
1264
+ let attemptCount = 0;
1265
+ const handler = {
1266
+ name: 'RetrySlice',
1267
+ events: ['RetrySliceDone', 'RetrySliceFailed'],
1268
+ handle: async () => {
1269
+ attemptCount++;
1270
+ if (attemptCount === 1) {
1271
+ return { type: 'RetrySliceFailed', data: {} };
1272
+ }
1273
+ return { type: 'RetrySliceDone', data: {} };
1274
+ },
1275
+ };
1276
+ const pipeline = define('test').on('Trigger').emit('RetrySlice', {}).build();
1277
+ const server = new PipelineServer({ port: 0 });
1278
+ server.registerCommandHandlers([handler]);
1279
+ server.registerPipeline(pipeline);
1280
+ server.registerItemKeyExtractor('RetrySlice', (d) => d.slicePath);
1281
+ await server.start();
1282
+ const correlationId = `corr-retry-test`;
1283
+ const slicePath = '/server/retry-slice';
1284
+ await fetch(`http://localhost:${server.port}/command`, {
1285
+ method: 'POST',
1286
+ headers: { 'Content-Type': 'application/json' },
1287
+ body: JSON.stringify({ type: 'RetrySlice', data: { slicePath }, correlationId }),
1288
+ });
1289
+ await new Promise((r) => setTimeout(r, 50));
1290
+ const afterFailure = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1291
+ expect(afterFailure.nodes.find((n) => n.id === 'cmd:RetrySlice')?.status).toBe('error');
1292
+ await fetch(`http://localhost:${server.port}/command`, {
1293
+ method: 'POST',
1294
+ headers: { 'Content-Type': 'application/json' },
1295
+ body: JSON.stringify({ type: 'RetrySlice', data: { slicePath }, correlationId }),
1296
+ });
1297
+ await new Promise((r) => setTimeout(r, 50));
1298
+ const afterRetry = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1299
+ const node = afterRetry.nodes.find((n) => n.id === 'cmd:RetrySlice');
1300
+ expect(node?.status).toBe('success');
1301
+ expect(node?.pendingCount).toBe(0);
1302
+ expect(node?.endedCount).toBe(1);
1303
+ await server.stop();
1304
+ });
1305
+ it('should include pendingCount and endedCount in NodeStatusChanged events', async () => {
1306
+ const handler = {
1307
+ name: 'CountSlice',
1308
+ events: ['CountSliceDone'],
1309
+ handle: async () => ({ type: 'CountSliceDone', data: {} }),
1310
+ };
1311
+ const pipeline = define('test').on('Trigger').emit('CountSlice', {}).build();
1312
+ const server = new PipelineServer({ port: 0 });
1313
+ server.registerCommandHandlers([handler]);
1314
+ server.registerPipeline(pipeline);
1315
+ server.registerItemKeyExtractor('CountSlice', (d) => d.id);
1316
+ await server.start();
1317
+ const correlationId = `corr-counts-event-test`;
1318
+ await fetch(`http://localhost:${server.port}/command`, {
1319
+ method: 'POST',
1320
+ headers: { 'Content-Type': 'application/json' },
1321
+ body: JSON.stringify({ type: 'CountSlice', data: { id: 'item-1' }, correlationId }),
1322
+ });
1323
+ await new Promise((r) => setTimeout(r, 100));
1324
+ const msgs = await fetchAs(`http://localhost:${server.port}/messages`);
1325
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
1326
+ const successEvent = nodeStatusChanged.find((m) => m.message.data?.status === 'success');
1327
+ expect(successEvent).toBeDefined();
1328
+ expect((successEvent?.message).data?.pendingCount).toBe(0);
1329
+ expect((successEvent?.message).data?.endedCount).toBe(1);
1330
+ await server.stop();
1331
+ });
1332
+ it('should use requestId as fallback when no itemKey extractor is registered', async () => {
1333
+ const handler = {
1334
+ name: 'NoExtractorCmd',
1335
+ events: ['NoExtractorDone'],
1336
+ handle: async () => ({ type: 'NoExtractorDone', data: {} }),
1337
+ };
1338
+ const pipeline = define('test').on('Trigger').emit('NoExtractorCmd', {}).build();
1339
+ const server = new PipelineServer({ port: 0 });
1340
+ server.registerCommandHandlers([handler]);
1341
+ server.registerPipeline(pipeline);
1342
+ await server.start();
1343
+ const correlationId = `corr-no-extractor-test`;
1344
+ await fetch(`http://localhost:${server.port}/command`, {
1345
+ method: 'POST',
1346
+ headers: { 'Content-Type': 'application/json' },
1347
+ body: JSON.stringify({ type: 'NoExtractorCmd', data: {}, correlationId }),
1348
+ });
1349
+ await new Promise((r) => setTimeout(r, 100));
1350
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline?correlationId=${correlationId}`);
1351
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:NoExtractorCmd');
1352
+ expect(cmdNode?.pendingCount).toBe(0);
1353
+ expect(cmdNode?.endedCount).toBe(1);
1354
+ expect(cmdNode?.status).toBe('success');
1355
+ await server.stop();
1356
+ });
1357
+ it('should show idle status with zero counts when no correlationId provided', async () => {
1358
+ const handler = {
1359
+ name: 'IdleCountCmd',
1360
+ events: ['IdleCountDone'],
1361
+ handle: async () => ({ type: 'IdleCountDone', data: {} }),
1362
+ };
1363
+ const pipeline = define('test').on('Trigger').emit('IdleCountCmd', {}).build();
1364
+ const server = new PipelineServer({ port: 0 });
1365
+ server.registerCommandHandlers([handler]);
1366
+ server.registerPipeline(pipeline);
1367
+ await server.start();
1368
+ const data = await fetchAs(`http://localhost:${server.port}/pipeline`);
1369
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:IdleCountCmd');
1370
+ expect(cmdNode?.status).toBe('idle');
1371
+ expect(cmdNode?.pendingCount).toBe(0);
1372
+ expect(cmdNode?.endedCount).toBe(0);
1373
+ await server.stop();
1374
+ });
1375
+ });
631
1376
  describe('integration', () => {
632
1377
  it('should execute complete workflow', async () => {
633
1378
  const handler = {