@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,154 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evolve, initialState } from './evolve';
|
|
3
|
+
import { classifyJobEvent, handleJobEvent, isJobFailure, parseCorrelationId } from './handle-job-event';
|
|
4
|
+
|
|
5
|
+
describe('parseCorrelationId', () => {
|
|
6
|
+
it('extracts graphId and jobId from graph correlation format', () => {
|
|
7
|
+
expect(parseCorrelationId('graph:g1:job-a')).toEqual({ graphId: 'g1', jobId: 'job-a' });
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('isJobFailure', () => {
|
|
12
|
+
it('returns true when event data contains error field', () => {
|
|
13
|
+
expect(isJobFailure({ type: 'BuildFailed', data: { error: 'compile error' } })).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('classifyJobEvent', () => {
|
|
18
|
+
it('returns null for events without graph correlation', () => {
|
|
19
|
+
expect(classifyJobEvent({ type: 'Unrelated', data: {} })).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns JobFailed when event has error field', () => {
|
|
23
|
+
const result = classifyJobEvent({
|
|
24
|
+
type: 'BuildFailed',
|
|
25
|
+
data: { error: 'compile error' },
|
|
26
|
+
correlationId: 'graph:g1:job-a',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(result).toEqual({
|
|
30
|
+
type: 'JobFailed',
|
|
31
|
+
data: { jobId: 'job-a', error: 'compile error' },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns JobSucceeded when event has no error', () => {
|
|
36
|
+
const result = classifyJobEvent({
|
|
37
|
+
type: 'BuildCompleted',
|
|
38
|
+
data: { output: 'success' },
|
|
39
|
+
correlationId: 'graph:g1:job-a',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
type: 'JobSucceeded',
|
|
44
|
+
data: { jobId: 'job-a', result: { output: 'success' } },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('handleJobEvent', () => {
|
|
50
|
+
it('returns null for events without graph correlation', () => {
|
|
51
|
+
const state = evolve(initialState(), {
|
|
52
|
+
type: 'GraphSubmitted',
|
|
53
|
+
data: {
|
|
54
|
+
graphId: 'g1',
|
|
55
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
56
|
+
failurePolicy: 'halt',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = handleJobEvent(state, { type: 'Unrelated', data: {} });
|
|
61
|
+
|
|
62
|
+
expect(result).toBe(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('emits JobSucceeded and dispatches ready dependents', () => {
|
|
66
|
+
let state = evolve(initialState(), {
|
|
67
|
+
type: 'GraphSubmitted',
|
|
68
|
+
data: {
|
|
69
|
+
graphId: 'g1',
|
|
70
|
+
jobs: [
|
|
71
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
72
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
73
|
+
],
|
|
74
|
+
failurePolicy: 'halt',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
state = evolve(state, {
|
|
78
|
+
type: 'JobDispatched',
|
|
79
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = handleJobEvent(state, {
|
|
83
|
+
type: 'BuildCompleted',
|
|
84
|
+
data: { output: 'ok' },
|
|
85
|
+
correlationId: 'graph:g1:a',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'a', result: { output: 'ok' } } }],
|
|
90
|
+
readyJobs: ['b'],
|
|
91
|
+
graphComplete: false,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('marks graph complete when last job succeeds', () => {
|
|
96
|
+
let state = evolve(initialState(), {
|
|
97
|
+
type: 'GraphSubmitted',
|
|
98
|
+
data: {
|
|
99
|
+
graphId: 'g1',
|
|
100
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
101
|
+
failurePolicy: 'halt',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
state = evolve(state, {
|
|
105
|
+
type: 'JobDispatched',
|
|
106
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = handleJobEvent(state, {
|
|
110
|
+
type: 'BuildCompleted',
|
|
111
|
+
data: { output: 'ok' },
|
|
112
|
+
correlationId: 'graph:g1:a',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'a', result: { output: 'ok' } } }],
|
|
117
|
+
readyJobs: [],
|
|
118
|
+
graphComplete: true,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('applies halt policy and marks graph complete on failure', () => {
|
|
123
|
+
let state = evolve(initialState(), {
|
|
124
|
+
type: 'GraphSubmitted',
|
|
125
|
+
data: {
|
|
126
|
+
graphId: 'g1',
|
|
127
|
+
jobs: [
|
|
128
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
129
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
130
|
+
],
|
|
131
|
+
failurePolicy: 'halt',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
state = evolve(state, {
|
|
135
|
+
type: 'JobDispatched',
|
|
136
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = handleJobEvent(state, {
|
|
140
|
+
type: 'BuildFailed',
|
|
141
|
+
data: { error: 'compile error' },
|
|
142
|
+
correlationId: 'graph:g1:a',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
events: [
|
|
147
|
+
{ type: 'JobFailed', data: { jobId: 'a', error: 'compile error' } },
|
|
148
|
+
{ type: 'JobSkipped', data: { jobId: 'b', reason: 'halt policy' } },
|
|
149
|
+
],
|
|
150
|
+
readyJobs: [],
|
|
151
|
+
graphComplete: true,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Event } from '@auto-engineer/message-bus';
|
|
2
|
+
import { applyPolicy } from './apply-policy';
|
|
3
|
+
import type { GraphState, JobGraphEvent } from './evolve';
|
|
4
|
+
import { evolve, getReadyJobs, isGraphComplete } from './evolve';
|
|
5
|
+
|
|
6
|
+
export function parseCorrelationId(correlationId: string): { graphId: string; jobId: string } | null {
|
|
7
|
+
const match = correlationId.match(/^graph:(.+):(.+)$/);
|
|
8
|
+
if (match === null) return null;
|
|
9
|
+
return { graphId: match[1], jobId: match[2] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isJobFailure(event: Event): boolean {
|
|
13
|
+
return 'error' in event.data && event.data.error !== undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function classifyJobEvent(event: Event): JobGraphEvent | null {
|
|
17
|
+
const parsed = parseCorrelationId(event.correlationId ?? '');
|
|
18
|
+
if (parsed === null) return null;
|
|
19
|
+
|
|
20
|
+
if (isJobFailure(event)) {
|
|
21
|
+
return {
|
|
22
|
+
type: 'JobFailed',
|
|
23
|
+
data: { jobId: parsed.jobId, error: String(event.data.error) },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
type: 'JobSucceeded',
|
|
29
|
+
data: { jobId: parsed.jobId, result: event.data },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface HandleResult {
|
|
34
|
+
events: JobGraphEvent[];
|
|
35
|
+
readyJobs: string[];
|
|
36
|
+
graphComplete: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function handleJobEvent(state: GraphState, event: Event): HandleResult | null {
|
|
40
|
+
const classified = classifyJobEvent(event);
|
|
41
|
+
if (classified === null) return null;
|
|
42
|
+
|
|
43
|
+
const events: JobGraphEvent[] = [classified];
|
|
44
|
+
let current = evolve(state, classified);
|
|
45
|
+
|
|
46
|
+
if (classified.type === 'JobFailed') {
|
|
47
|
+
const policyEvents = applyPolicy(current, classified.data.jobId);
|
|
48
|
+
for (const pe of policyEvents) {
|
|
49
|
+
events.push(pe);
|
|
50
|
+
current = evolve(current, pe);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
events,
|
|
56
|
+
readyJobs: getReadyJobs(current),
|
|
57
|
+
graphComplete: isGraphComplete(current),
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { commandHandler as processJobGraphHandler } from './commands/process-job-graph';
|
|
2
|
+
|
|
3
|
+
export const COMMANDS = [processJobGraphHandler];
|
|
4
|
+
|
|
5
|
+
export { applyPolicy } from './apply-policy';
|
|
6
|
+
export type { FailurePolicy, GraphState, JobGraphEvent, JobStatus } from './evolve';
|
|
7
|
+
|
|
8
|
+
export { evolve, getReadyJobs, getTransitiveDependents, initialState, isGraphComplete } from './evolve';
|
|
9
|
+
export { createGraphProcessor } from './graph-processor';
|
|
10
|
+
export type { Job } from './graph-validator';
|
|
11
|
+
export { validateGraph } from './graph-validator';
|
|
12
|
+
export { classifyJobEvent, handleJobEvent, isJobFailure, parseCorrelationId } from './handle-job-event';
|
|
13
|
+
export { handleProcessGraph } from './process-graph';
|
|
14
|
+
export type { RetryConfig, RetryManager } from './retry-manager';
|
|
15
|
+
export { createRetryManager } from './retry-manager';
|
|
16
|
+
export type { TimeoutManager } from './timeout-manager';
|
|
17
|
+
export { createTimeoutManager } from './timeout-manager';
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { evolve, getReadyJobs, initialState } from './evolve';
|
|
3
|
+
import { handleJobEvent } from './handle-job-event';
|
|
4
|
+
import { createRetryManager } from './retry-manager';
|
|
5
|
+
import { createTimeoutManager } from './timeout-manager';
|
|
6
|
+
|
|
7
|
+
describe('diamond dependency graph', () => {
|
|
8
|
+
it('processes a→(b,c)→d diamond graph to completion', () => {
|
|
9
|
+
let state = evolve(initialState(), {
|
|
10
|
+
type: 'GraphSubmitted',
|
|
11
|
+
data: {
|
|
12
|
+
graphId: 'g1',
|
|
13
|
+
jobs: [
|
|
14
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
15
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
16
|
+
{ id: 'c', dependsOn: ['a'], target: 'lint', payload: {} },
|
|
17
|
+
{ id: 'd', dependsOn: ['b', 'c'], target: 'deploy', payload: {} },
|
|
18
|
+
],
|
|
19
|
+
failurePolicy: 'halt',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(getReadyJobs(state)).toEqual(['a']);
|
|
24
|
+
state = evolve(state, {
|
|
25
|
+
type: 'JobDispatched',
|
|
26
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const r1 = handleJobEvent(state, {
|
|
30
|
+
type: 'BuildCompleted',
|
|
31
|
+
data: { output: 'ok' },
|
|
32
|
+
correlationId: 'graph:g1:a',
|
|
33
|
+
});
|
|
34
|
+
expect(r1).toEqual({
|
|
35
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'a', result: { output: 'ok' } } }],
|
|
36
|
+
readyJobs: ['b', 'c'],
|
|
37
|
+
graphComplete: false,
|
|
38
|
+
});
|
|
39
|
+
for (const e of r1!.events) state = evolve(state, e);
|
|
40
|
+
|
|
41
|
+
state = evolve(state, {
|
|
42
|
+
type: 'JobDispatched',
|
|
43
|
+
data: { jobId: 'b', target: 'test', correlationId: 'graph:g1:b' },
|
|
44
|
+
});
|
|
45
|
+
state = evolve(state, {
|
|
46
|
+
type: 'JobDispatched',
|
|
47
|
+
data: { jobId: 'c', target: 'lint', correlationId: 'graph:g1:c' },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const r2 = handleJobEvent(state, {
|
|
51
|
+
type: 'TestPassed',
|
|
52
|
+
data: {},
|
|
53
|
+
correlationId: 'graph:g1:b',
|
|
54
|
+
});
|
|
55
|
+
expect(r2).toEqual({
|
|
56
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'b', result: {} } }],
|
|
57
|
+
readyJobs: [],
|
|
58
|
+
graphComplete: false,
|
|
59
|
+
});
|
|
60
|
+
for (const e of r2!.events) state = evolve(state, e);
|
|
61
|
+
|
|
62
|
+
const r3 = handleJobEvent(state, {
|
|
63
|
+
type: 'LintPassed',
|
|
64
|
+
data: {},
|
|
65
|
+
correlationId: 'graph:g1:c',
|
|
66
|
+
});
|
|
67
|
+
expect(r3).toEqual({
|
|
68
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'c', result: {} } }],
|
|
69
|
+
readyJobs: ['d'],
|
|
70
|
+
graphComplete: false,
|
|
71
|
+
});
|
|
72
|
+
for (const e of r3!.events) state = evolve(state, e);
|
|
73
|
+
|
|
74
|
+
state = evolve(state, {
|
|
75
|
+
type: 'JobDispatched',
|
|
76
|
+
data: { jobId: 'd', target: 'deploy', correlationId: 'graph:g1:d' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const r4 = handleJobEvent(state, {
|
|
80
|
+
type: 'Deployed',
|
|
81
|
+
data: {},
|
|
82
|
+
correlationId: 'graph:g1:d',
|
|
83
|
+
});
|
|
84
|
+
expect(r4).toEqual({
|
|
85
|
+
events: [{ type: 'JobSucceeded', data: { jobId: 'd', result: {} } }],
|
|
86
|
+
readyJobs: [],
|
|
87
|
+
graphComplete: true,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('failure policies integration', () => {
|
|
93
|
+
it('halt policy skips all pending and completes graph on failure', () => {
|
|
94
|
+
let state = evolve(initialState(), {
|
|
95
|
+
type: 'GraphSubmitted',
|
|
96
|
+
data: {
|
|
97
|
+
graphId: 'g1',
|
|
98
|
+
jobs: [
|
|
99
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
100
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
101
|
+
{ id: 'c', dependsOn: [], target: 'lint', payload: {} },
|
|
102
|
+
],
|
|
103
|
+
failurePolicy: 'halt',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
state = evolve(state, {
|
|
107
|
+
type: 'JobDispatched',
|
|
108
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = handleJobEvent(state, {
|
|
112
|
+
type: 'BuildFailed',
|
|
113
|
+
data: { error: 'compile error' },
|
|
114
|
+
correlationId: 'graph:g1:a',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({
|
|
118
|
+
events: [
|
|
119
|
+
{ type: 'JobFailed', data: { jobId: 'a', error: 'compile error' } },
|
|
120
|
+
{ type: 'JobSkipped', data: { jobId: 'b', reason: 'halt policy' } },
|
|
121
|
+
{ type: 'JobSkipped', data: { jobId: 'c', reason: 'halt policy' } },
|
|
122
|
+
],
|
|
123
|
+
readyJobs: [],
|
|
124
|
+
graphComplete: true,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('skip-dependents policy skips only dependents and allows others to continue', () => {
|
|
129
|
+
let state = evolve(initialState(), {
|
|
130
|
+
type: 'GraphSubmitted',
|
|
131
|
+
data: {
|
|
132
|
+
graphId: 'g1',
|
|
133
|
+
jobs: [
|
|
134
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
135
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
136
|
+
{ id: 'c', dependsOn: [], target: 'lint', payload: {} },
|
|
137
|
+
],
|
|
138
|
+
failurePolicy: 'skip-dependents',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
state = evolve(state, {
|
|
142
|
+
type: 'JobDispatched',
|
|
143
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = handleJobEvent(state, {
|
|
147
|
+
type: 'BuildFailed',
|
|
148
|
+
data: { error: 'compile error' },
|
|
149
|
+
correlationId: 'graph:g1:a',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result).toEqual({
|
|
153
|
+
events: [
|
|
154
|
+
{ type: 'JobFailed', data: { jobId: 'a', error: 'compile error' } },
|
|
155
|
+
{ type: 'JobSkipped', data: { jobId: 'b', reason: 'dependency failed' } },
|
|
156
|
+
],
|
|
157
|
+
readyJobs: ['c'],
|
|
158
|
+
graphComplete: false,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('continue policy unlocks dependents after failure', () => {
|
|
163
|
+
let state = evolve(initialState(), {
|
|
164
|
+
type: 'GraphSubmitted',
|
|
165
|
+
data: {
|
|
166
|
+
graphId: 'g1',
|
|
167
|
+
jobs: [
|
|
168
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
169
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
170
|
+
],
|
|
171
|
+
failurePolicy: 'continue',
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
state = evolve(state, {
|
|
175
|
+
type: 'JobDispatched',
|
|
176
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = handleJobEvent(state, {
|
|
180
|
+
type: 'BuildFailed',
|
|
181
|
+
data: { error: 'compile error' },
|
|
182
|
+
correlationId: 'graph:g1:a',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result).toEqual({
|
|
186
|
+
events: [{ type: 'JobFailed', data: { jobId: 'a', error: 'compile error' } }],
|
|
187
|
+
readyJobs: ['b'],
|
|
188
|
+
graphComplete: false,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('timeout integration', () => {
|
|
194
|
+
it('fires timeout callback after elapsed time and evolve treats it as terminal', () => {
|
|
195
|
+
vi.useFakeTimers();
|
|
196
|
+
const timedOut: string[] = [];
|
|
197
|
+
const manager = createTimeoutManager((jobId) => timedOut.push(jobId));
|
|
198
|
+
|
|
199
|
+
let state = evolve(initialState(), {
|
|
200
|
+
type: 'GraphSubmitted',
|
|
201
|
+
data: {
|
|
202
|
+
graphId: 'g1',
|
|
203
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
204
|
+
failurePolicy: 'halt',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
state = evolve(state, {
|
|
208
|
+
type: 'JobDispatched',
|
|
209
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
manager.start('a', 5000);
|
|
213
|
+
vi.advanceTimersByTime(5000);
|
|
214
|
+
|
|
215
|
+
expect(timedOut).toEqual(['a']);
|
|
216
|
+
|
|
217
|
+
state = evolve(state, { type: 'JobTimedOut', data: { jobId: 'a', timeoutMs: 5000 } });
|
|
218
|
+
expect(getReadyJobs(state)).toEqual([]);
|
|
219
|
+
|
|
220
|
+
vi.useRealTimers();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('retry integration', () => {
|
|
225
|
+
it('retries with backoff then exhausts max retries', () => {
|
|
226
|
+
vi.useFakeTimers();
|
|
227
|
+
const retries: Array<{ jobId: string; attempt: number }> = [];
|
|
228
|
+
const manager = createRetryManager((jobId, attempt) => retries.push({ jobId, attempt }));
|
|
229
|
+
const config = { maxRetries: 2, backoffMs: 100, maxBackoffMs: 5000 };
|
|
230
|
+
|
|
231
|
+
const exhausted1 = manager.recordFailure('a', config);
|
|
232
|
+
expect(exhausted1).toBe(false);
|
|
233
|
+
vi.advanceTimersByTime(100);
|
|
234
|
+
expect(retries).toEqual([{ jobId: 'a', attempt: 1 }]);
|
|
235
|
+
|
|
236
|
+
const exhausted2 = manager.recordFailure('a', config);
|
|
237
|
+
expect(exhausted2).toBe(false);
|
|
238
|
+
vi.advanceTimersByTime(200);
|
|
239
|
+
expect(retries).toEqual([
|
|
240
|
+
{ jobId: 'a', attempt: 1 },
|
|
241
|
+
{ jobId: 'a', attempt: 2 },
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const exhausted3 = manager.recordFailure('a', config);
|
|
245
|
+
expect(exhausted3).toBe(true);
|
|
246
|
+
|
|
247
|
+
vi.useRealTimers();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleProcessGraph } from './process-graph';
|
|
3
|
+
|
|
4
|
+
describe('handleProcessGraph', () => {
|
|
5
|
+
it('returns graph.failed when graph validation fails', async () => {
|
|
6
|
+
const result = await handleProcessGraph({
|
|
7
|
+
type: 'ProcessGraph',
|
|
8
|
+
data: {
|
|
9
|
+
graphId: 'g1',
|
|
10
|
+
jobs: [],
|
|
11
|
+
failurePolicy: 'halt',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(result).toEqual({
|
|
16
|
+
type: 'graph.failed',
|
|
17
|
+
data: {
|
|
18
|
+
graphId: 'g1',
|
|
19
|
+
reason: 'Graph must contain at least one job',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('dispatches ready jobs for a valid graph', async () => {
|
|
25
|
+
const result = await handleProcessGraph({
|
|
26
|
+
type: 'ProcessGraph',
|
|
27
|
+
data: {
|
|
28
|
+
graphId: 'g1',
|
|
29
|
+
jobs: [
|
|
30
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } },
|
|
31
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
32
|
+
],
|
|
33
|
+
failurePolicy: 'halt',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual([
|
|
38
|
+
{
|
|
39
|
+
type: 'job.dispatched',
|
|
40
|
+
data: { graphId: 'g1', jobId: 'a', target: 'build', payload: { src: './app' } },
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Event } from '@auto-engineer/message-bus';
|
|
2
|
+
import type { FailurePolicy } from './evolve';
|
|
3
|
+
import { evolve, getReadyJobs, initialState } from './evolve';
|
|
4
|
+
import type { Job } from './graph-validator';
|
|
5
|
+
import { validateGraph } from './graph-validator';
|
|
6
|
+
|
|
7
|
+
interface ProcessGraphCommand {
|
|
8
|
+
type: 'ProcessGraph';
|
|
9
|
+
data: {
|
|
10
|
+
graphId: string;
|
|
11
|
+
jobs: readonly Job[];
|
|
12
|
+
failurePolicy: FailurePolicy;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function handleProcessGraph(command: ProcessGraphCommand): Promise<Event | Event[]> {
|
|
17
|
+
const { graphId, jobs, failurePolicy } = command.data;
|
|
18
|
+
|
|
19
|
+
const validation = validateGraph(jobs);
|
|
20
|
+
if (!validation.valid) {
|
|
21
|
+
return {
|
|
22
|
+
type: 'graph.failed',
|
|
23
|
+
data: { graphId, reason: validation.error },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const state = evolve(initialState(), {
|
|
28
|
+
type: 'GraphSubmitted',
|
|
29
|
+
data: { graphId, jobs, failurePolicy },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const jobById: Record<string, Job> = {};
|
|
33
|
+
for (const job of jobs) {
|
|
34
|
+
jobById[job.id] = job;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ready = getReadyJobs(state);
|
|
38
|
+
return ready.map((jobId) => ({
|
|
39
|
+
type: 'job.dispatched',
|
|
40
|
+
data: { graphId, jobId, target: jobById[jobId].target, payload: jobById[jobId].payload },
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { PipelineServer } from '@auto-engineer/pipeline';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { COMMANDS } from './index';
|
|
4
|
+
|
|
5
|
+
interface RegistryResponse {
|
|
6
|
+
commandHandlers: string[];
|
|
7
|
+
commandsWithMetadata: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
alias: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CommandAck {
|
|
15
|
+
status: string;
|
|
16
|
+
commandId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface StoredMessage {
|
|
20
|
+
message: { type: string; data?: Record<string, unknown> };
|
|
21
|
+
messageType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
|
25
|
+
const res = await fetch(url, options);
|
|
26
|
+
return res.json() as Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('ProcessJobGraph E2E', () => {
|
|
30
|
+
it('registry shows ProcessJobGraph command handler', async () => {
|
|
31
|
+
const server = new PipelineServer({ port: 0 });
|
|
32
|
+
server.registerCommandHandlers(COMMANDS);
|
|
33
|
+
await server.start();
|
|
34
|
+
|
|
35
|
+
const registry = await fetchJson<RegistryResponse>(`http://localhost:${server.port}/registry`);
|
|
36
|
+
|
|
37
|
+
expect(registry.commandHandlers).toContain('ProcessJobGraph');
|
|
38
|
+
expect(registry.commandsWithMetadata).toEqual(
|
|
39
|
+
expect.arrayContaining([
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
name: 'ProcessJobGraph',
|
|
42
|
+
alias: 'process:job-graph',
|
|
43
|
+
description: 'Process a directed acyclic graph of jobs with dependency tracking and failure policies',
|
|
44
|
+
}),
|
|
45
|
+
]),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await server.stop();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('command dispatch returns ack and produces graph.dispatching event', async () => {
|
|
52
|
+
const server = new PipelineServer({ port: 0 });
|
|
53
|
+
server.registerCommandHandlers(COMMANDS);
|
|
54
|
+
await server.start();
|
|
55
|
+
|
|
56
|
+
const ack = await fetchJson<CommandAck>(`http://localhost:${server.port}/command`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
type: 'ProcessJobGraph',
|
|
61
|
+
data: {
|
|
62
|
+
graphId: 'e2e-g1',
|
|
63
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } }],
|
|
64
|
+
failurePolicy: 'halt',
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(ack.status).toBe('ack');
|
|
70
|
+
|
|
71
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
72
|
+
|
|
73
|
+
const messages = await fetchJson<StoredMessage[]>(`http://localhost:${server.port}/messages`);
|
|
74
|
+
const eventTypes = messages.filter((m) => m.messageType === 'event').map((m) => m.message.type);
|
|
75
|
+
|
|
76
|
+
expect(eventTypes).toContain('graph.dispatching');
|
|
77
|
+
|
|
78
|
+
await server.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('full lifecycle produces graph.completed via correlation', async () => {
|
|
82
|
+
const server = new PipelineServer({ port: 0 });
|
|
83
|
+
server.registerCommandHandlers(COMMANDS);
|
|
84
|
+
await server.start();
|
|
85
|
+
|
|
86
|
+
const completed: Array<{ type: string; data: Record<string, unknown> }> = [];
|
|
87
|
+
server.getMessageBus().subscribeToEvent('graph.completed', {
|
|
88
|
+
name: 'e2e-completion-tracker',
|
|
89
|
+
handle: (event) => {
|
|
90
|
+
completed.push(event);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await fetchJson<CommandAck>(`http://localhost:${server.port}/command`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
type: 'ProcessJobGraph',
|
|
99
|
+
data: {
|
|
100
|
+
graphId: 'e2e-g2',
|
|
101
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
102
|
+
failurePolicy: 'halt',
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
108
|
+
|
|
109
|
+
await server.getMessageBus().publishEvent({
|
|
110
|
+
type: 'BuildCompleted',
|
|
111
|
+
data: { output: 'ok' },
|
|
112
|
+
correlationId: 'graph:e2e-g2:a',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
116
|
+
|
|
117
|
+
expect(completed).toEqual([{ type: 'graph.completed', data: { graphId: 'e2e-g2' } }]);
|
|
118
|
+
|
|
119
|
+
await server.stop();
|
|
120
|
+
});
|
|
121
|
+
});
|