@auto-engineer/job-graph-processor 1.28.0 → 1.30.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 (37) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +61 -0
  5. package/dist/src/commands/process-job-graph.d.ts.map +1 -1
  6. package/dist/src/commands/process-job-graph.js +15 -1
  7. package/dist/src/commands/process-job-graph.js.map +1 -1
  8. package/dist/src/evolve.d.ts +1 -0
  9. package/dist/src/evolve.d.ts.map +1 -1
  10. package/dist/src/evolve.js +10 -0
  11. package/dist/src/evolve.js.map +1 -1
  12. package/dist/src/evolve.specs.js +64 -1
  13. package/dist/src/evolve.specs.js.map +1 -1
  14. package/dist/src/graph-processor.d.ts +2 -0
  15. package/dist/src/graph-processor.d.ts.map +1 -1
  16. package/dist/src/graph-processor.js +4 -2
  17. package/dist/src/graph-processor.js.map +1 -1
  18. package/dist/src/graph-processor.specs.js +44 -5
  19. package/dist/src/graph-processor.specs.js.map +1 -1
  20. package/dist/src/index.d.ts +1 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +1 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/pipeline-e2e.specs.d.ts +2 -0
  25. package/dist/src/pipeline-e2e.specs.d.ts.map +1 -0
  26. package/dist/src/pipeline-e2e.specs.js +127 -0
  27. package/dist/src/pipeline-e2e.specs.js.map +1 -0
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/ketchup-plan.md +11 -0
  30. package/package.json +3 -3
  31. package/src/commands/process-job-graph.ts +17 -2
  32. package/src/evolve.specs.ts +73 -1
  33. package/src/evolve.ts +10 -0
  34. package/src/graph-processor.specs.ts +48 -5
  35. package/src/graph-processor.ts +10 -3
  36. package/src/index.ts +1 -1
  37. package/src/pipeline-e2e.specs.ts +157 -0
@@ -1,12 +1,13 @@
1
1
  import type { Command, Event, MessageBus } from '@auto-engineer/message-bus';
2
2
  import type { FailurePolicy } from '../evolve';
3
- import type { DispatchFn } from '../graph-processor';
3
+ import type { DispatchFn, PublishEventFn } from '../graph-processor';
4
4
  import { createGraphProcessor, type ProcessGraphCommand } from '../graph-processor';
5
5
  import type { Job } from '../graph-validator';
6
6
 
7
7
  interface ContextWithMessageBus {
8
8
  messageBus: MessageBus;
9
9
  sendCommand?: (type: string, data: unknown, correlationId?: string) => Promise<void>;
10
+ emit?: (type: string, data: unknown) => Promise<void>;
10
11
  }
11
12
 
12
13
  function isContextWithMessageBus(context: unknown): context is ContextWithMessageBus {
@@ -27,6 +28,15 @@ function buildDispatch(context: ContextWithMessageBus): DispatchFn | undefined {
27
28
  return (cmd) => ctxSendCommand(cmd.type, cmd.data, cmd.correlationId);
28
29
  }
29
30
 
31
+ function buildPublishEvent(context: ContextWithMessageBus): PublishEventFn | undefined {
32
+ if (typeof context.emit !== 'function') return undefined;
33
+ const ctxEmit = context.emit;
34
+ return (event) => {
35
+ context.messageBus.publishEvent(event);
36
+ ctxEmit(event.type, event.data);
37
+ };
38
+ }
39
+
30
40
  function toProcessGraphCommand(command: Command): ProcessGraphCommand {
31
41
  const data = command.data as { graphId: string; jobs: Job[]; failurePolicy: FailurePolicy };
32
42
  return { type: command.type, data };
@@ -60,6 +70,7 @@ export const commandHandler = {
60
70
  { name: 'GraphDispatched', displayName: 'Graph Dispatched' },
61
71
  { name: 'GraphFailed', displayName: 'Graph Failed' },
62
72
  { name: 'GraphProcessed', displayName: 'Graph Processed' },
73
+ { name: 'GraphProcessingFailed', displayName: 'Graph Processing Failed' },
63
74
  ],
64
75
  handle: async (command: Command, context?: unknown): Promise<Event | Event[]> => {
65
76
  const { graphId } = command.data;
@@ -72,7 +83,11 @@ export const commandHandler = {
72
83
  }
73
84
 
74
85
  const dispatch = buildDispatch(context);
75
- const processor = createGraphProcessor(context.messageBus, dispatch ? { dispatch } : undefined);
86
+ const publishEvent = buildPublishEvent(context);
87
+ const processor = createGraphProcessor(context.messageBus, {
88
+ ...(dispatch !== undefined && { dispatch }),
89
+ ...(publishEvent !== undefined && { publishEvent }),
90
+ });
76
91
  return processor.submit(toProcessGraphCommand(command));
77
92
  },
78
93
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { evolve, getReadyJobs, getTransitiveDependents, initialState, isGraphComplete } from './evolve';
2
+ import { evolve, getReadyJobs, getTransitiveDependents, hasFailedJobs, initialState, isGraphComplete } from './evolve';
3
3
 
4
4
  describe('evolve', () => {
5
5
  it('ignores job events before graph submission', () => {
@@ -233,3 +233,75 @@ describe('isGraphComplete', () => {
233
233
  expect(isGraphComplete(state)).toBe(true);
234
234
  });
235
235
  });
236
+
237
+ describe('hasFailedJobs', () => {
238
+ it('returns false before graph submission', () => {
239
+ expect(hasFailedJobs(initialState())).toBe(false);
240
+ });
241
+
242
+ it('returns false when all jobs succeeded', () => {
243
+ let state = evolve(initialState(), {
244
+ type: 'GraphSubmitted',
245
+ data: {
246
+ graphId: 'g1',
247
+ jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
248
+ failurePolicy: 'halt',
249
+ },
250
+ });
251
+ state = evolve(state, {
252
+ type: 'JobDispatched',
253
+ data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
254
+ });
255
+ state = evolve(state, { type: 'JobSucceeded', data: { jobId: 'a' } });
256
+
257
+ expect(hasFailedJobs(state)).toBe(false);
258
+ });
259
+
260
+ it('returns true when a job failed', () => {
261
+ let state = evolve(initialState(), {
262
+ type: 'GraphSubmitted',
263
+ data: {
264
+ graphId: 'g1',
265
+ jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
266
+ failurePolicy: 'halt',
267
+ },
268
+ });
269
+ state = evolve(state, {
270
+ type: 'JobDispatched',
271
+ data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
272
+ });
273
+ state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
274
+
275
+ expect(hasFailedJobs(state)).toBe(true);
276
+ });
277
+
278
+ it('returns true when a job was skipped', () => {
279
+ let state = evolve(initialState(), {
280
+ type: 'GraphSubmitted',
281
+ data: {
282
+ graphId: 'g1',
283
+ jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
284
+ failurePolicy: 'halt',
285
+ },
286
+ });
287
+ state = evolve(state, { type: 'JobSkipped', data: { jobId: 'a', reason: 'dependency failed' } });
288
+
289
+ expect(hasFailedJobs(state)).toBe(true);
290
+ });
291
+
292
+ it('returns false when jobs are still pending', () => {
293
+ const state = evolve(initialState(), {
294
+ type: 'GraphSubmitted',
295
+ data: {
296
+ graphId: 'g1',
297
+ jobs: [
298
+ { id: 'a', dependsOn: [], target: 'build', payload: {} },
299
+ { id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
300
+ ],
301
+ failurePolicy: 'halt',
302
+ },
303
+ });
304
+
305
+ expect(hasFailedJobs(state)).toBe(false);
306
+ });
307
+ });
package/src/evolve.ts CHANGED
@@ -85,6 +85,16 @@ export function isGraphComplete(state: GraphState): boolean {
85
85
  return true;
86
86
  }
87
87
 
88
+ const FAILED_STATUSES: ReadonlySet<JobStatus> = new Set(['failed', 'skipped', 'timed-out']);
89
+
90
+ export function hasFailedJobs(state: GraphState): boolean {
91
+ if (state.status !== 'processing') return false;
92
+ for (const job of state.jobs.values()) {
93
+ if (FAILED_STATUSES.has(job.status)) return true;
94
+ }
95
+ return false;
96
+ }
97
+
88
98
  export function getTransitiveDependents(state: GraphState, jobId: string): string[] {
89
99
  if (state.status !== 'processing') return [];
90
100
  const dependents: Set<string> = new Set();
@@ -297,14 +297,56 @@ describe('createGraphProcessor', () => {
297
297
  });
298
298
  });
299
299
 
300
- it('applies halt policy when job fails via correlation', async () => {
300
+ it('uses custom publishEvent instead of messageBus.publishEvent when provided', async () => {
301
+ const bus = createMessageBus();
302
+ const published: Array<{ type: string; data: Record<string, unknown> }> = [];
303
+ const busPublished: Array<{ type: string; data: Record<string, unknown> }> = [];
304
+ bus.subscribeToEvent('GraphProcessed', {
305
+ name: 'busTracker',
306
+ handle: (event) => {
307
+ busPublished.push(event);
308
+ },
309
+ });
310
+ const processor = createGraphProcessor(bus, {
311
+ publishEvent: (event) => {
312
+ published.push(event as { type: string; data: Record<string, unknown> });
313
+ },
314
+ });
315
+
316
+ processor.submit({
317
+ type: 'ProcessGraph',
318
+ data: {
319
+ graphId: 'g1',
320
+ jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
321
+ failurePolicy: 'halt',
322
+ },
323
+ });
324
+
325
+ await bus.publishEvent({
326
+ type: 'BuildCompleted',
327
+ data: { output: 'ok' },
328
+ correlationId: 'graph:g1:a',
329
+ });
330
+
331
+ expect(published).toEqual([{ type: 'GraphProcessed', data: { graphId: 'g1' } }]);
332
+ expect(busPublished).toEqual([]);
333
+ });
334
+
335
+ it('publishes GraphProcessingFailed when a job fails via correlation', async () => {
301
336
  const bus = createMessageBus();
302
337
  const processor = createGraphProcessor(bus);
303
- const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
338
+ const failed: Array<{ type: string; data: Record<string, unknown> }> = [];
339
+ const succeeded: Array<{ type: string; data: Record<string, unknown> }> = [];
340
+ bus.subscribeToEvent('GraphProcessingFailed', {
341
+ name: 'failureTracker',
342
+ handle: (event) => {
343
+ failed.push(event);
344
+ },
345
+ });
304
346
  bus.subscribeToEvent('GraphProcessed', {
305
- name: 'completionTracker',
347
+ name: 'successTracker',
306
348
  handle: (event) => {
307
- completed.push(event);
349
+ succeeded.push(event);
308
350
  },
309
351
  });
310
352
 
@@ -326,6 +368,7 @@ describe('createGraphProcessor', () => {
326
368
  correlationId: 'graph:g1:a',
327
369
  });
328
370
 
329
- expect(completed).toEqual([{ type: 'GraphProcessed', data: { graphId: 'g1' } }]);
371
+ expect(failed).toEqual([{ type: 'GraphProcessingFailed', data: { graphId: 'g1' } }]);
372
+ expect(succeeded).toEqual([]);
330
373
  });
331
374
  });
@@ -1,7 +1,7 @@
1
1
  import type { Event, MessageBus } from '@auto-engineer/message-bus';
2
2
  import { applyPolicy } from './apply-policy';
3
3
  import type { FailurePolicy, GraphState } from './evolve';
4
- import { evolve, getReadyJobs, initialState, isGraphComplete } from './evolve';
4
+ import { evolve, getReadyJobs, hasFailedJobs, initialState, isGraphComplete } from './evolve';
5
5
  import type { Job } from './graph-validator';
6
6
  import { validateGraph } from './graph-validator';
7
7
  import { classifyJobEvent } from './handle-job-event';
@@ -24,7 +24,12 @@ interface DispatchedJob {
24
24
 
25
25
  export type DispatchFn = (command: { type: string; data: unknown; correlationId: string }) => Promise<void>;
26
26
 
27
- export function createGraphProcessor(messageBus: MessageBus, options?: { dispatch?: DispatchFn }) {
27
+ export type PublishEventFn = (event: Event) => void;
28
+
29
+ export function createGraphProcessor(
30
+ messageBus: MessageBus,
31
+ options?: { dispatch?: DispatchFn; publishEvent?: PublishEventFn },
32
+ ) {
28
33
  const dispatch: DispatchFn =
29
34
  options?.dispatch ??
30
35
  ((cmd) =>
@@ -33,6 +38,7 @@ export function createGraphProcessor(messageBus: MessageBus, options?: { dispatc
33
38
  data: cmd.data as Record<string, unknown>,
34
39
  correlationId: cmd.correlationId,
35
40
  }));
41
+ const publishEvent: PublishEventFn = options?.publishEvent ?? ((event) => messageBus.publishEvent(event));
36
42
  const graphs = new Map<string, { state: GraphState; jobById: Record<string, Job> }>();
37
43
 
38
44
  function submit(command: ProcessGraphCommand): Event {
@@ -113,7 +119,8 @@ export function createGraphProcessor(messageBus: MessageBus, options?: { dispatc
113
119
 
114
120
  if (isGraphComplete(state)) {
115
121
  graphs.delete(graphId);
116
- messageBus.publishEvent({ type: 'GraphProcessed', data: { graphId } });
122
+ const eventType = hasFailedJobs(state) ? 'GraphProcessingFailed' : 'GraphProcessed';
123
+ publishEvent({ type: eventType, data: { graphId } });
117
124
  }
118
125
  }
119
126
 
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ export const COMMANDS = [processJobGraphHandler];
5
5
  export { applyPolicy } from './apply-policy';
6
6
  export type { FailurePolicy, GraphState, JobGraphEvent, JobStatus } from './evolve';
7
7
 
8
- export { evolve, getReadyJobs, getTransitiveDependents, initialState, isGraphComplete } from './evolve';
8
+ export { evolve, getReadyJobs, getTransitiveDependents, hasFailedJobs, initialState, isGraphComplete } from './evolve';
9
9
  export { createGraphProcessor } from './graph-processor';
10
10
  export type { Job } from './graph-validator';
11
11
  export { validateGraph } from './graph-validator';
@@ -0,0 +1,157 @@
1
+ import type { Command, Event } from '@auto-engineer/message-bus';
2
+ import type { CommandHandlerWithMetadata } from '@auto-engineer/pipeline';
3
+ import { define, PipelineServer } from '@auto-engineer/pipeline';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { commandHandler as processJobGraphHandler } from './commands/process-job-graph';
6
+
7
+ interface StoredMessage {
8
+ message: { type: string; data?: Record<string, unknown> };
9
+ messageType: string;
10
+ }
11
+
12
+ async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
13
+ const res = await fetch(url, options);
14
+ return res.json() as Promise<T>;
15
+ }
16
+
17
+ function delay(ms: number): Promise<void> {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ describe('Job Graph Pipeline E2E', () => {
22
+ it('routes GraphProcessed through pipeline to trigger downstream command', async () => {
23
+ const implementCalls: Command[] = [];
24
+
25
+ const mockJobHandler: CommandHandlerWithMetadata = {
26
+ name: 'MockJob',
27
+ events: ['MockJobCompleted'],
28
+ handle: async () => ({ type: 'MockJobCompleted', data: { result: 'ok' } }),
29
+ };
30
+
31
+ const implementHandler: CommandHandlerWithMetadata = {
32
+ name: 'ImplementReactApp',
33
+ events: ['ReactAppImplemented'],
34
+ handle: async (cmd: Command) => {
35
+ implementCalls.push(cmd);
36
+ return { type: 'ReactAppImplemented', data: { clientDir: cmd.data.clientDir } };
37
+ },
38
+ };
39
+
40
+ const pipeline = define('job-graph-e2e')
41
+ .on('JobGraphReady')
42
+ .emit('ProcessJobGraph', (e: Event) => ({
43
+ graphId: e.data.graphId,
44
+ jobs: e.data.jobs,
45
+ failurePolicy: e.data.failurePolicy,
46
+ }))
47
+ .on('GraphProcessed')
48
+ .emit('ImplementReactApp', { clientDir: './client' })
49
+ .build();
50
+
51
+ const server = new PipelineServer({ port: 0 });
52
+ server.registerCommandHandlers([
53
+ processJobGraphHandler as CommandHandlerWithMetadata,
54
+ mockJobHandler,
55
+ implementHandler,
56
+ ]);
57
+ server.registerPipeline(pipeline);
58
+ await server.start();
59
+
60
+ const triggerEvent: Event = {
61
+ type: 'JobGraphReady',
62
+ data: {
63
+ graphId: 'e2e-happy',
64
+ jobs: [
65
+ { id: 'a', dependsOn: [], target: 'MockJob', payload: {} },
66
+ { id: 'b', dependsOn: ['a'], target: 'MockJob', payload: {} },
67
+ ],
68
+ failurePolicy: 'halt',
69
+ },
70
+ };
71
+
72
+ await server.getMessageBus().publishEvent(triggerEvent);
73
+ await server.emitPipelineStartedEvent();
74
+
75
+ await fetchJson(`http://localhost:${server.port}/command`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({ type: 'ProcessJobGraph', data: triggerEvent.data }),
79
+ });
80
+
81
+ await delay(500);
82
+
83
+ const messages = await fetchJson<StoredMessage[]>(`http://localhost:${server.port}/messages`);
84
+ const eventTypes = messages.filter((m) => m.messageType === 'event').map((m) => m.message.type);
85
+
86
+ expect(eventTypes).toContain('GraphDispatched');
87
+ expect(eventTypes).toContain('GraphProcessed');
88
+ expect(eventTypes).toContain('ReactAppImplemented');
89
+ expect(implementCalls.length).toBeGreaterThanOrEqual(1);
90
+
91
+ await server.stop();
92
+ });
93
+
94
+ it('routes GraphProcessingFailed and does NOT trigger downstream success handler', async () => {
95
+ const implementCalls: Command[] = [];
96
+
97
+ const failingJobHandler: CommandHandlerWithMetadata = {
98
+ name: 'FailingJob',
99
+ events: ['FailingJobFailed'],
100
+ handle: async () => ({
101
+ type: 'FailingJobFailed',
102
+ data: { error: 'build failed' },
103
+ }),
104
+ };
105
+
106
+ const implementHandler: CommandHandlerWithMetadata = {
107
+ name: 'ImplementReactApp',
108
+ events: ['ReactAppImplemented'],
109
+ handle: async (cmd: Command) => {
110
+ implementCalls.push(cmd);
111
+ return { type: 'ReactAppImplemented', data: { clientDir: cmd.data.clientDir } };
112
+ },
113
+ };
114
+
115
+ const pipeline = define('job-graph-fail-e2e')
116
+ .on('GraphProcessed')
117
+ .emit('ImplementReactApp', { clientDir: './client' })
118
+ .build();
119
+
120
+ const server = new PipelineServer({ port: 0 });
121
+ server.registerCommandHandlers([
122
+ processJobGraphHandler as CommandHandlerWithMetadata,
123
+ failingJobHandler,
124
+ implementHandler,
125
+ ]);
126
+ server.registerPipeline(pipeline);
127
+ await server.start();
128
+
129
+ await fetchJson(`http://localhost:${server.port}/command`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({
133
+ type: 'ProcessJobGraph',
134
+ data: {
135
+ graphId: 'e2e-fail',
136
+ jobs: [
137
+ { id: 'a', dependsOn: [], target: 'FailingJob', payload: {} },
138
+ { id: 'b', dependsOn: ['a'], target: 'FailingJob', payload: {} },
139
+ ],
140
+ failurePolicy: 'halt',
141
+ },
142
+ }),
143
+ });
144
+
145
+ await delay(500);
146
+
147
+ const messages = await fetchJson<StoredMessage[]>(`http://localhost:${server.port}/messages`);
148
+ const eventTypes = messages.filter((m) => m.messageType === 'event').map((m) => m.message.type);
149
+
150
+ expect(eventTypes).toContain('GraphDispatched');
151
+ expect(eventTypes).toContain('GraphProcessingFailed');
152
+ expect(eventTypes).not.toContain('GraphProcessed');
153
+ expect(implementCalls).toEqual([]);
154
+
155
+ await server.stop();
156
+ });
157
+ });