@auto-engineer/job-graph-processor 1.12.1
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 +5 -0
- package/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-type-check.log +4 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +10 -0
- package/README.md +408 -0
- package/dist/src/apply-policy.d.ts +3 -0
- package/dist/src/apply-policy.d.ts.map +1 -0
- package/dist/src/apply-policy.js +24 -0
- package/dist/src/apply-policy.js.map +1 -0
- package/dist/src/apply-policy.specs.d.ts +2 -0
- package/dist/src/apply-policy.specs.d.ts.map +1 -0
- package/dist/src/apply-policy.specs.js +75 -0
- package/dist/src/apply-policy.specs.js.map +1 -0
- package/dist/src/commands/process-job-graph.d.ts +31 -0
- package/dist/src/commands/process-job-graph.d.ts.map +1 -0
- package/dist/src/commands/process-job-graph.js +64 -0
- package/dist/src/commands/process-job-graph.js.map +1 -0
- package/dist/src/commands/process-job-graph.specs.d.ts +2 -0
- package/dist/src/commands/process-job-graph.specs.d.ts.map +1 -0
- package/dist/src/commands/process-job-graph.specs.js +73 -0
- package/dist/src/commands/process-job-graph.specs.js.map +1 -0
- package/dist/src/evolve.d.ts +70 -0
- package/dist/src/evolve.d.ts.map +1 -0
- package/dist/src/evolve.js +82 -0
- package/dist/src/evolve.js.map +1 -0
- package/dist/src/evolve.specs.d.ts +2 -0
- package/dist/src/evolve.specs.d.ts.map +1 -0
- package/dist/src/evolve.specs.js +209 -0
- package/dist/src/evolve.specs.js.map +1 -0
- package/dist/src/graph-processor.d.ts +22 -0
- package/dist/src/graph-processor.d.ts.map +1 -0
- package/dist/src/graph-processor.js +82 -0
- package/dist/src/graph-processor.js.map +1 -0
- package/dist/src/graph-processor.specs.d.ts +2 -0
- package/dist/src/graph-processor.specs.d.ts.map +1 -0
- package/dist/src/graph-processor.specs.js +286 -0
- package/dist/src/graph-processor.specs.js.map +1 -0
- package/dist/src/graph-validator.d.ts +19 -0
- package/dist/src/graph-validator.d.ts.map +1 -0
- package/dist/src/graph-validator.js +65 -0
- package/dist/src/graph-validator.js.map +1 -0
- package/dist/src/graph-validator.specs.d.ts +2 -0
- package/dist/src/graph-validator.specs.d.ts.map +1 -0
- package/dist/src/graph-validator.specs.js +86 -0
- package/dist/src/graph-validator.specs.js.map +1 -0
- package/dist/src/handle-job-event.d.ts +16 -0
- package/dist/src/handle-job-event.d.ts.map +1 -0
- package/dist/src/handle-job-event.js +46 -0
- package/dist/src/handle-job-event.js.map +1 -0
- package/dist/src/handle-job-event.specs.d.ts +2 -0
- package/dist/src/handle-job-event.specs.d.ts.map +1 -0
- package/dist/src/handle-job-event.specs.js +136 -0
- package/dist/src/handle-job-event.specs.js.map +1 -0
- package/dist/src/index.d.ts +41 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +11 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integration.specs.d.ts +2 -0
- package/dist/src/integration.specs.d.ts.map +1 -0
- package/dist/src/integration.specs.js +225 -0
- package/dist/src/integration.specs.js.map +1 -0
- package/dist/src/process-graph.d.ts +14 -0
- package/dist/src/process-graph.d.ts.map +1 -0
- package/dist/src/process-graph.js +26 -0
- package/dist/src/process-graph.js.map +1 -0
- package/dist/src/process-graph.specs.d.ts +2 -0
- package/dist/src/process-graph.specs.d.ts.map +1 -0
- package/dist/src/process-graph.specs.js +41 -0
- package/dist/src/process-graph.specs.js.map +1 -0
- package/dist/src/process-job-graph.e2e.specs.d.ts +2 -0
- package/dist/src/process-job-graph.e2e.specs.d.ts.map +1 -0
- package/dist/src/process-job-graph.e2e.specs.js +81 -0
- package/dist/src/process-job-graph.e2e.specs.js.map +1 -0
- package/dist/src/retry-manager.d.ts +10 -0
- package/dist/src/retry-manager.d.ts.map +1 -0
- package/dist/src/retry-manager.js +17 -0
- package/dist/src/retry-manager.js.map +1 -0
- package/dist/src/retry-manager.specs.d.ts +2 -0
- package/dist/src/retry-manager.specs.d.ts.map +1 -0
- package/dist/src/retry-manager.specs.js +55 -0
- package/dist/src/retry-manager.specs.js.map +1 -0
- package/dist/src/timeout-manager.d.ts +7 -0
- package/dist/src/timeout-manager.d.ts.map +1 -0
- package/dist/src/timeout-manager.js +25 -0
- package/dist/src/timeout-manager.js.map +1 -0
- package/dist/src/timeout-manager.specs.d.ts +2 -0
- package/dist/src/timeout-manager.specs.d.ts.map +1 -0
- package/dist/src/timeout-manager.specs.js +44 -0
- package/dist/src/timeout-manager.specs.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/ketchup-plan.md +31 -0
- package/package.json +25 -0
- package/src/apply-policy.specs.ts +85 -0
- package/src/apply-policy.ts +27 -0
- package/src/commands/process-job-graph.specs.ts +93 -0
- package/src/commands/process-job-graph.ts +80 -0
- package/src/evolve.specs.ts +235 -0
- package/src/evolve.ts +121 -0
- package/src/graph-processor.specs.ts +331 -0
- package/src/graph-processor.ts +121 -0
- package/src/graph-validator.specs.ts +105 -0
- package/src/graph-validator.ts +94 -0
- package/src/handle-job-event.specs.ts +154 -0
- package/src/handle-job-event.ts +59 -0
- package/src/index.ts +17 -0
- package/src/integration.specs.ts +249 -0
- package/src/process-graph.specs.ts +44 -0
- package/src/process-graph.ts +42 -0
- package/src/process-job-graph.e2e.specs.ts +121 -0
- package/src/retry-manager.specs.ts +66 -0
- package/src/retry-manager.ts +29 -0
- package/src/timeout-manager.specs.ts +55 -0
- package/src/timeout-manager.ts +34 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { createMessageBus } from '@auto-engineer/message-bus';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createGraphProcessor } from './graph-processor';
|
|
4
|
+
|
|
5
|
+
describe('createGraphProcessor', () => {
|
|
6
|
+
it('rejects duplicate graph submissions', () => {
|
|
7
|
+
const bus = createMessageBus();
|
|
8
|
+
const processor = createGraphProcessor(bus);
|
|
9
|
+
const command = {
|
|
10
|
+
type: 'ProcessGraph' as const,
|
|
11
|
+
data: {
|
|
12
|
+
graphId: 'g1',
|
|
13
|
+
jobs: [{ id: 'a', dependsOn: [] as string[], target: 'build', payload: {} }],
|
|
14
|
+
failurePolicy: 'halt' as const,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
processor.submit(command);
|
|
19
|
+
const second = processor.submit(command);
|
|
20
|
+
|
|
21
|
+
expect(second).toEqual({
|
|
22
|
+
type: 'graph.failed',
|
|
23
|
+
data: { graphId: 'g1', reason: 'Graph g1 already submitted' },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects invalid graph', () => {
|
|
28
|
+
const bus = createMessageBus();
|
|
29
|
+
const processor = createGraphProcessor(bus);
|
|
30
|
+
|
|
31
|
+
const result = processor.submit({
|
|
32
|
+
type: 'ProcessGraph',
|
|
33
|
+
data: { graphId: 'g1', jobs: [], failurePolicy: 'halt' },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
type: 'graph.failed',
|
|
38
|
+
data: { graphId: 'g1', reason: 'Graph must contain at least one job' },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('dispatches ready jobs and returns dispatching event', () => {
|
|
43
|
+
const bus = createMessageBus();
|
|
44
|
+
const processor = createGraphProcessor(bus);
|
|
45
|
+
|
|
46
|
+
const result = processor.submit({
|
|
47
|
+
type: 'ProcessGraph',
|
|
48
|
+
data: {
|
|
49
|
+
graphId: 'g1',
|
|
50
|
+
jobs: [
|
|
51
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } },
|
|
52
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
53
|
+
],
|
|
54
|
+
failurePolicy: 'halt',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result).toEqual({
|
|
59
|
+
type: 'graph.dispatching',
|
|
60
|
+
data: {
|
|
61
|
+
graphId: 'g1',
|
|
62
|
+
dispatchedJobs: [{ jobId: 'a', target: 'build', payload: { src: './app' }, correlationId: 'graph:g1:a' }],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('processes correlated events and emits graph.completed', async () => {
|
|
68
|
+
const bus = createMessageBus();
|
|
69
|
+
const processor = createGraphProcessor(bus);
|
|
70
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
71
|
+
bus.subscribeToEvent('graph.completed', {
|
|
72
|
+
name: 'completionTracker',
|
|
73
|
+
handle: (event) => {
|
|
74
|
+
completed.push(event);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
processor.submit({
|
|
79
|
+
type: 'ProcessGraph',
|
|
80
|
+
data: {
|
|
81
|
+
graphId: 'g1',
|
|
82
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
83
|
+
failurePolicy: 'halt',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await bus.publishEvent({
|
|
88
|
+
type: 'BuildCompleted',
|
|
89
|
+
data: { output: 'ok' },
|
|
90
|
+
correlationId: 'graph:g1:a',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(completed).toEqual([{ type: 'graph.completed', data: { graphId: 'g1' } }]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('dispatches dependent jobs when deps complete via correlation', async () => {
|
|
97
|
+
const bus = createMessageBus();
|
|
98
|
+
const processor = createGraphProcessor(bus);
|
|
99
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
100
|
+
bus.subscribeToEvent('graph.completed', {
|
|
101
|
+
name: 'completionTracker',
|
|
102
|
+
handle: (event) => {
|
|
103
|
+
completed.push(event);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
processor.submit({
|
|
108
|
+
type: 'ProcessGraph',
|
|
109
|
+
data: {
|
|
110
|
+
graphId: 'g1',
|
|
111
|
+
jobs: [
|
|
112
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
113
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
114
|
+
],
|
|
115
|
+
failurePolicy: 'halt',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await bus.publishEvent({
|
|
120
|
+
type: 'BuildCompleted',
|
|
121
|
+
data: {},
|
|
122
|
+
correlationId: 'graph:g1:a',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await bus.publishEvent({
|
|
126
|
+
type: 'TestPassed',
|
|
127
|
+
data: {},
|
|
128
|
+
correlationId: 'graph:g1:b',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(completed).toEqual([{ type: 'graph.completed', data: { graphId: 'g1' } }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ignores correlated events after graph completes', async () => {
|
|
135
|
+
const bus = createMessageBus();
|
|
136
|
+
const processor = createGraphProcessor(bus);
|
|
137
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
138
|
+
bus.subscribeToEvent('graph.completed', {
|
|
139
|
+
name: 'completionTracker',
|
|
140
|
+
handle: (event) => {
|
|
141
|
+
completed.push(event);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
processor.submit({
|
|
146
|
+
type: 'ProcessGraph',
|
|
147
|
+
data: {
|
|
148
|
+
graphId: 'g1',
|
|
149
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
150
|
+
failurePolicy: 'halt',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await bus.publishEvent({
|
|
155
|
+
type: 'BuildCompleted',
|
|
156
|
+
data: {},
|
|
157
|
+
correlationId: 'graph:g1:a',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await bus.publishEvent({
|
|
161
|
+
type: 'LateEvent',
|
|
162
|
+
data: {},
|
|
163
|
+
correlationId: 'graph:g1:a',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(completed).toEqual([{ type: 'graph.completed', data: { graphId: 'g1' } }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ignores events with unrecognized correlationId format', async () => {
|
|
170
|
+
const bus = createMessageBus();
|
|
171
|
+
const processor = createGraphProcessor(bus);
|
|
172
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
173
|
+
bus.subscribeToEvent('graph.completed', {
|
|
174
|
+
name: 'completionTracker',
|
|
175
|
+
handle: (event) => {
|
|
176
|
+
completed.push(event);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
processor.submit({
|
|
181
|
+
type: 'ProcessGraph',
|
|
182
|
+
data: {
|
|
183
|
+
graphId: 'g1',
|
|
184
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
185
|
+
failurePolicy: 'halt',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await bus.publishEvent({
|
|
190
|
+
type: 'WeirdEvent',
|
|
191
|
+
data: {},
|
|
192
|
+
correlationId: 'graph:g1:',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(completed).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('sends target commands for initial ready jobs', async () => {
|
|
199
|
+
const bus = createMessageBus();
|
|
200
|
+
const processor = createGraphProcessor(bus);
|
|
201
|
+
const received: Array<{ type: string; data: unknown; correlationId?: string }> = [];
|
|
202
|
+
bus.registerCommandHandler({
|
|
203
|
+
name: 'build',
|
|
204
|
+
handle: async (command) => {
|
|
205
|
+
received.push({ type: command.type, data: command.data, correlationId: command.correlationId });
|
|
206
|
+
return { type: 'BuildCompleted', data: {} };
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
processor.submit({
|
|
211
|
+
type: 'ProcessGraph',
|
|
212
|
+
data: {
|
|
213
|
+
graphId: 'g1',
|
|
214
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } }],
|
|
215
|
+
failurePolicy: 'halt',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
220
|
+
|
|
221
|
+
expect(received).toEqual([{ type: 'build', data: { src: './app' }, correlationId: 'graph:g1:a' }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('sends target commands for newly ready dependent jobs', async () => {
|
|
225
|
+
const bus = createMessageBus();
|
|
226
|
+
const processor = createGraphProcessor(bus);
|
|
227
|
+
const received: Array<{ type: string; data: unknown; correlationId?: string }> = [];
|
|
228
|
+
bus.registerCommandHandler({
|
|
229
|
+
name: 'build',
|
|
230
|
+
handle: async (command) => {
|
|
231
|
+
received.push({ type: command.type, data: command.data, correlationId: command.correlationId });
|
|
232
|
+
return { type: 'BuildCompleted', data: {} };
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
bus.registerCommandHandler({
|
|
236
|
+
name: 'test',
|
|
237
|
+
handle: async (command) => {
|
|
238
|
+
received.push({ type: command.type, data: command.data, correlationId: command.correlationId });
|
|
239
|
+
return { type: 'TestPassed', data: {} };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
processor.submit({
|
|
244
|
+
type: 'ProcessGraph',
|
|
245
|
+
data: {
|
|
246
|
+
graphId: 'g1',
|
|
247
|
+
jobs: [
|
|
248
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } },
|
|
249
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: { suite: 'unit' } },
|
|
250
|
+
],
|
|
251
|
+
failurePolicy: 'halt',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
256
|
+
|
|
257
|
+
expect(received).toEqual([
|
|
258
|
+
{ type: 'build', data: { src: './app' }, correlationId: 'graph:g1:a' },
|
|
259
|
+
{ type: 'test', data: { suite: 'unit' }, correlationId: 'graph:g1:b' },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('uses dispatch callback instead of messageBus.sendCommand when provided', async () => {
|
|
264
|
+
const bus = createMessageBus();
|
|
265
|
+
const dispatched: Array<{ type: string; data: unknown; correlationId: string }> = [];
|
|
266
|
+
const dispatch = async (command: { type: string; data: unknown; correlationId: string }) => {
|
|
267
|
+
dispatched.push(command);
|
|
268
|
+
};
|
|
269
|
+
const processor = createGraphProcessor(bus, { dispatch });
|
|
270
|
+
|
|
271
|
+
processor.submit({
|
|
272
|
+
type: 'ProcessGraph',
|
|
273
|
+
data: {
|
|
274
|
+
graphId: 'g1',
|
|
275
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } }],
|
|
276
|
+
failurePolicy: 'halt',
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
281
|
+
|
|
282
|
+
expect(dispatched).toEqual([{ type: 'build', data: { src: './app' }, correlationId: 'graph:g1:a' }]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rejects command with missing jobs', () => {
|
|
286
|
+
const bus = createMessageBus();
|
|
287
|
+
const processor = createGraphProcessor(bus);
|
|
288
|
+
|
|
289
|
+
const result = processor.submit({
|
|
290
|
+
type: 'ProcessGraph',
|
|
291
|
+
data: { graphId: 'g1', failurePolicy: 'halt' } as any,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result).toEqual({
|
|
295
|
+
type: 'graph.failed',
|
|
296
|
+
data: { graphId: 'g1', reason: 'jobs is required and must be an array' },
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('applies halt policy when job fails via correlation', async () => {
|
|
301
|
+
const bus = createMessageBus();
|
|
302
|
+
const processor = createGraphProcessor(bus);
|
|
303
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
304
|
+
bus.subscribeToEvent('graph.completed', {
|
|
305
|
+
name: 'completionTracker',
|
|
306
|
+
handle: (event) => {
|
|
307
|
+
completed.push(event);
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
processor.submit({
|
|
312
|
+
type: 'ProcessGraph',
|
|
313
|
+
data: {
|
|
314
|
+
graphId: 'g1',
|
|
315
|
+
jobs: [
|
|
316
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
317
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
318
|
+
],
|
|
319
|
+
failurePolicy: 'halt',
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await bus.publishEvent({
|
|
324
|
+
type: 'BuildFailed',
|
|
325
|
+
data: { error: 'compile error' },
|
|
326
|
+
correlationId: 'graph:g1:a',
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(completed).toEqual([{ type: 'graph.completed', data: { graphId: 'g1' } }]);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Event, MessageBus } from '@auto-engineer/message-bus';
|
|
2
|
+
import { applyPolicy } from './apply-policy';
|
|
3
|
+
import type { FailurePolicy, GraphState } from './evolve';
|
|
4
|
+
import { evolve, getReadyJobs, initialState, isGraphComplete } from './evolve';
|
|
5
|
+
import type { Job } from './graph-validator';
|
|
6
|
+
import { validateGraph } from './graph-validator';
|
|
7
|
+
import { classifyJobEvent } from './handle-job-event';
|
|
8
|
+
|
|
9
|
+
export interface ProcessGraphCommand {
|
|
10
|
+
type: string;
|
|
11
|
+
data: {
|
|
12
|
+
graphId: string;
|
|
13
|
+
jobs: readonly Job[];
|
|
14
|
+
failurePolicy: FailurePolicy;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DispatchedJob {
|
|
19
|
+
jobId: string;
|
|
20
|
+
target: string;
|
|
21
|
+
payload: unknown;
|
|
22
|
+
correlationId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DispatchFn = (command: { type: string; data: unknown; correlationId: string }) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
export function createGraphProcessor(messageBus: MessageBus, options?: { dispatch?: DispatchFn }) {
|
|
28
|
+
const dispatch: DispatchFn =
|
|
29
|
+
options?.dispatch ??
|
|
30
|
+
((cmd) =>
|
|
31
|
+
messageBus.sendCommand({
|
|
32
|
+
type: cmd.type,
|
|
33
|
+
data: cmd.data as Record<string, unknown>,
|
|
34
|
+
correlationId: cmd.correlationId,
|
|
35
|
+
}));
|
|
36
|
+
const graphs = new Map<string, { state: GraphState; jobById: Record<string, Job> }>();
|
|
37
|
+
|
|
38
|
+
function submit(command: ProcessGraphCommand): Event {
|
|
39
|
+
const { graphId, jobs, failurePolicy } = command.data;
|
|
40
|
+
|
|
41
|
+
if (graphs.has(graphId)) {
|
|
42
|
+
return { type: 'graph.failed', data: { graphId, reason: `Graph ${graphId} already submitted` } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!Array.isArray(jobs)) {
|
|
46
|
+
return { type: 'graph.failed', data: { graphId, reason: 'jobs is required and must be an array' } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const validation = validateGraph(jobs);
|
|
50
|
+
if (!validation.valid) {
|
|
51
|
+
return { type: 'graph.failed', data: { graphId, reason: validation.error } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let state = evolve(initialState(), {
|
|
55
|
+
type: 'GraphSubmitted',
|
|
56
|
+
data: { graphId, jobs, failurePolicy },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const ready = getReadyJobs(state);
|
|
60
|
+
const jobById: Record<string, Job> = {};
|
|
61
|
+
for (const job of jobs) {
|
|
62
|
+
jobById[job.id] = job;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dispatched: DispatchedJob[] = ready.map((jobId) => {
|
|
66
|
+
const correlationId = `graph:${graphId}:${jobId}`;
|
|
67
|
+
state = evolve(state, {
|
|
68
|
+
type: 'JobDispatched',
|
|
69
|
+
data: { jobId, target: jobById[jobId].target, correlationId },
|
|
70
|
+
});
|
|
71
|
+
return { jobId, target: jobById[jobId].target, payload: jobById[jobId].payload, correlationId };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
graphs.set(graphId, { state, jobById });
|
|
75
|
+
|
|
76
|
+
messageBus.onCorrelationPrefix(`graph:${graphId}:`, (event) => {
|
|
77
|
+
onJobEvent(graphId, event);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
for (const d of dispatched) {
|
|
81
|
+
dispatch({ type: d.target, data: d.payload, correlationId: d.correlationId }).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { type: 'graph.dispatching', data: { graphId, dispatchedJobs: dispatched } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onJobEvent(graphId: string, event: Event): void {
|
|
88
|
+
const entry = graphs.get(graphId);
|
|
89
|
+
if (entry === undefined) return;
|
|
90
|
+
|
|
91
|
+
const classified = classifyJobEvent(event);
|
|
92
|
+
if (classified === null) return;
|
|
93
|
+
let state = evolve(entry.state, classified);
|
|
94
|
+
|
|
95
|
+
if (classified.type === 'JobFailed') {
|
|
96
|
+
const policyEvents = applyPolicy(state, classified.data.jobId);
|
|
97
|
+
for (const pe of policyEvents) {
|
|
98
|
+
state = evolve(state, pe);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const jobId of getReadyJobs(state)) {
|
|
103
|
+
const correlationId = `graph:${graphId}:${jobId}`;
|
|
104
|
+
const { target, payload } = entry.jobById[jobId];
|
|
105
|
+
state = evolve(state, {
|
|
106
|
+
type: 'JobDispatched',
|
|
107
|
+
data: { jobId, target, correlationId },
|
|
108
|
+
});
|
|
109
|
+
dispatch({ type: target, data: payload, correlationId }).catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
graphs.set(graphId, { ...entry, state });
|
|
113
|
+
|
|
114
|
+
if (isGraphComplete(state)) {
|
|
115
|
+
graphs.delete(graphId);
|
|
116
|
+
messageBus.publishEvent({ type: 'graph.completed', data: { graphId } });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { submit };
|
|
121
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Job } from './graph-validator';
|
|
3
|
+
import { validateGraph } from './graph-validator';
|
|
4
|
+
|
|
5
|
+
describe('validateGraph', () => {
|
|
6
|
+
it('returns valid for a correct graph with no dependencies', () => {
|
|
7
|
+
const jobs: Job[] = [
|
|
8
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
9
|
+
{ id: 'b', dependsOn: [], target: 'test', payload: {} },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
expect(validateGraph(jobs)).toEqual({ valid: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns valid for a correct graph with dependencies', () => {
|
|
16
|
+
const jobs: Job[] = [
|
|
17
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
18
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
expect(validateGraph(jobs)).toEqual({ valid: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns error for duplicate job IDs', () => {
|
|
25
|
+
const jobs: Job[] = [
|
|
26
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
27
|
+
{ id: 'a', dependsOn: [], target: 'test', payload: {} },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
expect(validateGraph(jobs)).toEqual({
|
|
31
|
+
valid: false,
|
|
32
|
+
error: "Duplicate job ID: 'a'",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns error for missing dependency', () => {
|
|
37
|
+
const jobs: Job[] = [{ id: 'a', dependsOn: ['missing'], target: 'build', payload: {} }];
|
|
38
|
+
|
|
39
|
+
expect(validateGraph(jobs)).toEqual({
|
|
40
|
+
valid: false,
|
|
41
|
+
error: "Job 'a' depends on unknown job 'missing'",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns error for self-reference', () => {
|
|
46
|
+
const jobs: Job[] = [{ id: 'a', dependsOn: ['a'], target: 'build', payload: {} }];
|
|
47
|
+
|
|
48
|
+
expect(validateGraph(jobs)).toEqual({
|
|
49
|
+
valid: false,
|
|
50
|
+
error: "Job 'a' depends on itself",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns error for cycle', () => {
|
|
55
|
+
const jobs: Job[] = [
|
|
56
|
+
{ id: 'a', dependsOn: ['b'], target: 'build', payload: {} },
|
|
57
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
expect(validateGraph(jobs)).toEqual({
|
|
61
|
+
valid: false,
|
|
62
|
+
error: "Cycle detected involving job 'a'",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns error for transitive cycle', () => {
|
|
67
|
+
const jobs: Job[] = [
|
|
68
|
+
{ id: 'a', dependsOn: ['c'], target: 'build', payload: {} },
|
|
69
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
70
|
+
{ id: 'c', dependsOn: ['b'], target: 'lint', payload: {} },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
expect(validateGraph(jobs)).toEqual({
|
|
74
|
+
valid: false,
|
|
75
|
+
error: "Cycle detected involving job 'a'",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns error for empty target', () => {
|
|
80
|
+
const jobs: Job[] = [{ id: 'a', dependsOn: [], target: '', payload: {} }];
|
|
81
|
+
|
|
82
|
+
expect(validateGraph(jobs)).toEqual({
|
|
83
|
+
valid: false,
|
|
84
|
+
error: "Job 'a' has empty target",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns valid for diamond dependency graph', () => {
|
|
89
|
+
const jobs: Job[] = [
|
|
90
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
91
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
92
|
+
{ id: 'c', dependsOn: ['a'], target: 'lint', payload: {} },
|
|
93
|
+
{ id: 'd', dependsOn: ['b', 'c'], target: 'deploy', payload: {} },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
expect(validateGraph(jobs)).toEqual({ valid: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns error for empty jobs array', () => {
|
|
100
|
+
expect(validateGraph([])).toEqual({
|
|
101
|
+
valid: false,
|
|
102
|
+
error: 'Graph must contain at least one job',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export interface Job {
|
|
2
|
+
id: string;
|
|
3
|
+
dependsOn: readonly string[];
|
|
4
|
+
target: string;
|
|
5
|
+
payload: unknown;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
retries?: number;
|
|
8
|
+
backoffMs?: number;
|
|
9
|
+
maxBackoffMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ValidationResult = { valid: true } | { valid: false; error: string };
|
|
13
|
+
|
|
14
|
+
export function validateGraph(jobs: readonly Job[]): ValidationResult {
|
|
15
|
+
if (jobs.length === 0) {
|
|
16
|
+
return { valid: false, error: 'Graph must contain at least one job' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ids = new Set<string>();
|
|
20
|
+
for (const job of jobs) {
|
|
21
|
+
if (ids.has(job.id)) {
|
|
22
|
+
return { valid: false, error: `Duplicate job ID: '${job.id}'` };
|
|
23
|
+
}
|
|
24
|
+
ids.add(job.id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const job of jobs) {
|
|
28
|
+
if (job.target === '') {
|
|
29
|
+
return { valid: false, error: `Job '${job.id}' has empty target` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const dep of job.dependsOn) {
|
|
33
|
+
if (dep === job.id) {
|
|
34
|
+
return { valid: false, error: `Job '${job.id}' depends on itself` };
|
|
35
|
+
}
|
|
36
|
+
if (!ids.has(dep)) {
|
|
37
|
+
return { valid: false, error: `Job '${job.id}' depends on unknown job '${dep}'` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cycleError = detectCycles(jobs);
|
|
43
|
+
if (cycleError !== null) {
|
|
44
|
+
return { valid: false, error: cycleError };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { valid: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectCycles(jobs: readonly Job[]): string | null {
|
|
51
|
+
const adjacency: Record<string, readonly string[]> = {};
|
|
52
|
+
for (const job of jobs) {
|
|
53
|
+
adjacency[job.id] = job.dependsOn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visited = new Set<string>();
|
|
57
|
+
const inStack = new Set<string>();
|
|
58
|
+
|
|
59
|
+
for (const job of jobs) {
|
|
60
|
+
if (!visited.has(job.id)) {
|
|
61
|
+
const cycleNode = dfs(job.id, adjacency, visited, inStack);
|
|
62
|
+
if (cycleNode !== null) {
|
|
63
|
+
return `Cycle detected involving job '${cycleNode}'`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dfs(
|
|
72
|
+
nodeId: string,
|
|
73
|
+
adjacency: Record<string, readonly string[]>,
|
|
74
|
+
visited: Set<string>,
|
|
75
|
+
inStack: Set<string>,
|
|
76
|
+
): string | null {
|
|
77
|
+
visited.add(nodeId);
|
|
78
|
+
inStack.add(nodeId);
|
|
79
|
+
|
|
80
|
+
for (const dep of adjacency[nodeId]) {
|
|
81
|
+
if (inStack.has(dep)) {
|
|
82
|
+
return dep;
|
|
83
|
+
}
|
|
84
|
+
if (!visited.has(dep)) {
|
|
85
|
+
const result = dfs(dep, adjacency, visited, inStack);
|
|
86
|
+
if (result !== null) {
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
inStack.delete(nodeId);
|
|
93
|
+
return null;
|
|
94
|
+
}
|