@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +26 -0
- package/README.md +279 -0
- package/dist/src/builder/define.d.ts +6 -2
- package/dist/src/builder/define.d.ts.map +1 -1
- package/dist/src/builder/define.js +17 -7
- package/dist/src/builder/define.js.map +1 -1
- package/dist/src/builder/define.specs.js +3 -3
- package/dist/src/builder/define.specs.js.map +1 -1
- package/dist/src/core/descriptors.d.ts +6 -2
- package/dist/src/core/descriptors.d.ts.map +1 -1
- package/dist/src/graph/filter-graph.d.ts +3 -0
- package/dist/src/graph/filter-graph.d.ts.map +1 -0
- package/dist/src/graph/filter-graph.js +80 -0
- package/dist/src/graph/filter-graph.js.map +1 -0
- package/dist/src/graph/filter-graph.specs.d.ts +2 -0
- package/dist/src/graph/filter-graph.specs.d.ts.map +1 -0
- package/dist/src/graph/filter-graph.specs.js +204 -0
- package/dist/src/graph/filter-graph.specs.js.map +1 -0
- package/dist/src/graph/types.d.ts +8 -0
- package/dist/src/graph/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/projections/await-tracker-projection.d.ts +31 -0
- package/dist/src/projections/await-tracker-projection.d.ts.map +1 -0
- package/dist/src/projections/await-tracker-projection.js +35 -0
- package/dist/src/projections/await-tracker-projection.js.map +1 -0
- package/dist/src/projections/index.d.ts +4 -0
- package/dist/src/projections/index.d.ts.map +1 -0
- package/dist/src/projections/index.js +4 -0
- package/dist/src/projections/index.js.map +1 -0
- package/dist/src/projections/item-status-projection.d.ts +22 -0
- package/dist/src/projections/item-status-projection.d.ts.map +1 -0
- package/dist/src/projections/item-status-projection.js +11 -0
- package/dist/src/projections/item-status-projection.js.map +1 -0
- package/dist/src/projections/item-status-projection.specs.d.ts +2 -0
- package/dist/src/projections/item-status-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/item-status-projection.specs.js +119 -0
- package/dist/src/projections/item-status-projection.specs.js.map +1 -0
- package/dist/src/projections/latest-run-projection.d.ts +15 -0
- package/dist/src/projections/latest-run-projection.d.ts.map +1 -0
- package/dist/src/projections/latest-run-projection.js +7 -0
- package/dist/src/projections/latest-run-projection.js.map +1 -0
- package/dist/src/projections/latest-run-projection.specs.d.ts +2 -0
- package/dist/src/projections/latest-run-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/latest-run-projection.specs.js +33 -0
- package/dist/src/projections/latest-run-projection.specs.js.map +1 -0
- package/dist/src/projections/message-log-projection.d.ts +51 -0
- package/dist/src/projections/message-log-projection.d.ts.map +1 -0
- package/dist/src/projections/message-log-projection.js +51 -0
- package/dist/src/projections/message-log-projection.js.map +1 -0
- package/dist/src/projections/message-log-projection.specs.d.ts +2 -0
- package/dist/src/projections/message-log-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/message-log-projection.specs.js +101 -0
- package/dist/src/projections/message-log-projection.specs.js.map +1 -0
- package/dist/src/projections/node-status-projection.d.ts +23 -0
- package/dist/src/projections/node-status-projection.d.ts.map +1 -0
- package/dist/src/projections/node-status-projection.js +10 -0
- package/dist/src/projections/node-status-projection.js.map +1 -0
- package/dist/src/projections/node-status-projection.specs.d.ts +2 -0
- package/dist/src/projections/node-status-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/node-status-projection.specs.js +116 -0
- package/dist/src/projections/node-status-projection.specs.js.map +1 -0
- package/dist/src/projections/phased-execution-projection.d.ts +77 -0
- package/dist/src/projections/phased-execution-projection.d.ts.map +1 -0
- package/dist/src/projections/phased-execution-projection.js +54 -0
- package/dist/src/projections/phased-execution-projection.js.map +1 -0
- package/dist/src/projections/phased-execution-projection.specs.d.ts +2 -0
- package/dist/src/projections/phased-execution-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/phased-execution-projection.specs.js +171 -0
- package/dist/src/projections/phased-execution-projection.specs.js.map +1 -0
- package/dist/src/projections/settled-instance-projection.d.ts +67 -0
- package/dist/src/projections/settled-instance-projection.d.ts.map +1 -0
- package/dist/src/projections/settled-instance-projection.js +66 -0
- package/dist/src/projections/settled-instance-projection.js.map +1 -0
- package/dist/src/projections/settled-instance-projection.specs.d.ts +2 -0
- package/dist/src/projections/settled-instance-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/settled-instance-projection.specs.js +217 -0
- package/dist/src/projections/settled-instance-projection.specs.js.map +1 -0
- package/dist/src/projections/stats-projection.d.ts +9 -0
- package/dist/src/projections/stats-projection.d.ts.map +1 -0
- package/dist/src/projections/stats-projection.js +16 -0
- package/dist/src/projections/stats-projection.js.map +1 -0
- package/dist/src/projections/stats-projection.specs.d.ts +2 -0
- package/dist/src/projections/stats-projection.specs.d.ts.map +1 -0
- package/dist/src/projections/stats-projection.specs.js +91 -0
- package/dist/src/projections/stats-projection.specs.js.map +1 -0
- package/dist/src/runtime/await-tracker.d.ts +17 -7
- package/dist/src/runtime/await-tracker.d.ts.map +1 -1
- package/dist/src/runtime/await-tracker.js +32 -29
- package/dist/src/runtime/await-tracker.js.map +1 -1
- package/dist/src/runtime/await-tracker.specs.js +56 -38
- package/dist/src/runtime/await-tracker.specs.js.map +1 -1
- package/dist/src/runtime/context.d.ts +1 -1
- package/dist/src/runtime/context.d.ts.map +1 -1
- package/dist/src/runtime/event-command-map.d.ts +3 -3
- package/dist/src/runtime/event-command-map.d.ts.map +1 -1
- package/dist/src/runtime/event-command-map.js +6 -2
- package/dist/src/runtime/event-command-map.js.map +1 -1
- package/dist/src/runtime/phased-executor.d.ts +15 -9
- package/dist/src/runtime/phased-executor.d.ts.map +1 -1
- package/dist/src/runtime/phased-executor.js +126 -104
- package/dist/src/runtime/phased-executor.js.map +1 -1
- package/dist/src/runtime/phased-executor.specs.js +243 -81
- package/dist/src/runtime/phased-executor.specs.js.map +1 -1
- package/dist/src/runtime/pipeline-runtime.d.ts.map +1 -1
- package/dist/src/runtime/pipeline-runtime.js +2 -2
- package/dist/src/runtime/pipeline-runtime.js.map +1 -1
- package/dist/src/runtime/pipeline-runtime.specs.js +35 -0
- package/dist/src/runtime/pipeline-runtime.specs.js.map +1 -1
- package/dist/src/runtime/settled-tracker.d.ts +12 -9
- package/dist/src/runtime/settled-tracker.d.ts.map +1 -1
- package/dist/src/runtime/settled-tracker.js +92 -77
- package/dist/src/runtime/settled-tracker.js.map +1 -1
- package/dist/src/runtime/settled-tracker.specs.js +568 -118
- package/dist/src/runtime/settled-tracker.specs.js.map +1 -1
- package/dist/src/server/pipeline-server.d.ts +31 -9
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.e2e.specs.js +2 -10
- package/dist/src/server/pipeline-server.e2e.specs.js.map +1 -1
- package/dist/src/server/pipeline-server.js +418 -134
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/src/server/pipeline-server.specs.js +777 -32
- package/dist/src/server/pipeline-server.specs.js.map +1 -1
- package/dist/src/server/sse-manager.specs.js +55 -35
- package/dist/src/server/sse-manager.specs.js.map +1 -1
- package/dist/src/store/index.d.ts +3 -0
- package/dist/src/store/index.d.ts.map +1 -0
- package/dist/src/store/index.js +3 -0
- package/dist/src/store/index.js.map +1 -0
- package/dist/src/store/pipeline-event-store.d.ts +10 -0
- package/dist/src/store/pipeline-event-store.d.ts.map +1 -0
- package/dist/src/store/pipeline-event-store.js +112 -0
- package/dist/src/store/pipeline-event-store.js.map +1 -0
- package/dist/src/store/pipeline-event-store.specs.d.ts +2 -0
- package/dist/src/store/pipeline-event-store.specs.d.ts.map +1 -0
- package/dist/src/store/pipeline-event-store.specs.js +287 -0
- package/dist/src/store/pipeline-event-store.specs.js.map +1 -0
- package/dist/src/store/pipeline-read-model.d.ts +49 -0
- package/dist/src/store/pipeline-read-model.d.ts.map +1 -0
- package/dist/src/store/pipeline-read-model.js +157 -0
- package/dist/src/store/pipeline-read-model.js.map +1 -0
- package/dist/src/store/pipeline-read-model.specs.d.ts +2 -0
- package/dist/src/store/pipeline-read-model.specs.d.ts.map +1 -0
- package/dist/src/store/pipeline-read-model.specs.js +830 -0
- package/dist/src/store/pipeline-read-model.specs.js.map +1 -0
- package/dist/src/testing/fixtures/kanban-full.pipeline.js +2 -2
- package/dist/src/testing/fixtures/kanban-full.pipeline.js.map +1 -1
- package/dist/src/testing/fixtures/kanban.pipeline.js +2 -2
- package/dist/src/testing/fixtures/kanban.pipeline.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +960 -0
- package/package.json +7 -3
- package/src/builder/define.specs.ts +3 -3
- package/src/builder/define.ts +24 -11
- package/src/core/descriptors.ts +7 -2
- package/src/graph/filter-graph.specs.ts +241 -0
- package/src/graph/filter-graph.ts +111 -0
- package/src/graph/types.ts +10 -0
- package/src/index.ts +1 -2
- package/src/projections/await-tracker-projection.ts +68 -0
- package/src/projections/index.ts +11 -0
- package/src/projections/item-status-projection.specs.ts +130 -0
- package/src/projections/item-status-projection.ts +32 -0
- package/src/projections/latest-run-projection.specs.ts +38 -0
- package/src/projections/latest-run-projection.ts +20 -0
- package/src/projections/message-log-projection.specs.ts +118 -0
- package/src/projections/message-log-projection.ts +113 -0
- package/src/projections/node-status-projection.specs.ts +127 -0
- package/src/projections/node-status-projection.ts +33 -0
- package/src/projections/phased-execution-projection.specs.ts +202 -0
- package/src/projections/phased-execution-projection.ts +146 -0
- package/src/projections/settled-instance-projection.specs.ts +249 -0
- package/src/projections/settled-instance-projection.ts +160 -0
- package/src/projections/stats-projection.specs.ts +105 -0
- package/src/projections/stats-projection.ts +26 -0
- package/src/runtime/await-tracker.specs.ts +57 -34
- package/src/runtime/await-tracker.ts +43 -31
- package/src/runtime/context.ts +1 -1
- package/src/runtime/event-command-map.ts +11 -4
- package/src/runtime/phased-executor.specs.ts +357 -81
- package/src/runtime/phased-executor.ts +142 -126
- package/src/runtime/pipeline-runtime.specs.ts +42 -0
- package/src/runtime/pipeline-runtime.ts +6 -4
- package/src/runtime/settled-tracker.specs.ts +716 -120
- package/src/runtime/settled-tracker.ts +104 -98
- package/src/server/pipeline-server.e2e.specs.ts +10 -16
- package/src/server/pipeline-server.specs.ts +964 -49
- package/src/server/pipeline-server.ts +522 -156
- package/src/server/sse-manager.specs.ts +67 -36
- package/src/store/index.ts +2 -0
- package/src/store/pipeline-event-store.specs.ts +309 -0
- package/src/store/pipeline-event-store.ts +156 -0
- package/src/store/pipeline-read-model.specs.ts +967 -0
- package/src/store/pipeline-read-model.ts +223 -0
- package/src/testing/fixtures/kanban-full.pipeline.ts +2 -2
- package/src/testing/fixtures/kanban.pipeline.ts +2 -2
- package/claude.md +0 -160
- package/docs/testing-analysis.md +0 -395
- 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
|
|
107
|
+
it('should use displayName as label for command graph nodes', async () => {
|
|
108
108
|
const handler = {
|
|
109
|
-
name: '
|
|
110
|
-
|
|
111
|
-
handle: async () => ({ type: '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
204
|
+
it('should not have status on event nodes', async () => {
|
|
130
205
|
const handler = {
|
|
131
206
|
name: 'Cmd',
|
|
132
|
-
|
|
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
|
|
141
|
-
expect(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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('
|
|
496
|
-
expect(mermaid).toContain('
|
|
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
|
|
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 = {
|