@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,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyPolicy } from './apply-policy';
|
|
3
|
+
import { evolve, initialState } from './evolve';
|
|
4
|
+
|
|
5
|
+
describe('applyPolicy', () => {
|
|
6
|
+
it('returns empty array before graph submission', () => {
|
|
7
|
+
const events = applyPolicy(initialState(), 'a');
|
|
8
|
+
|
|
9
|
+
expect(events).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('halt policy skips all pending jobs when a job fails', () => {
|
|
13
|
+
let state = evolve(initialState(), {
|
|
14
|
+
type: 'GraphSubmitted',
|
|
15
|
+
data: {
|
|
16
|
+
graphId: 'g1',
|
|
17
|
+
jobs: [
|
|
18
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
19
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
20
|
+
{ id: 'c', dependsOn: ['a'], target: 'lint', payload: {} },
|
|
21
|
+
],
|
|
22
|
+
failurePolicy: 'halt',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
state = evolve(state, {
|
|
26
|
+
type: 'JobDispatched',
|
|
27
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
28
|
+
});
|
|
29
|
+
state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
|
|
30
|
+
|
|
31
|
+
const events = applyPolicy(state, 'a');
|
|
32
|
+
|
|
33
|
+
expect(events).toEqual([
|
|
34
|
+
{ type: 'JobSkipped', data: { jobId: 'b', reason: 'halt policy' } },
|
|
35
|
+
{ type: 'JobSkipped', data: { jobId: 'c', reason: 'halt policy' } },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('skip-dependents policy skips only transitive dependents of failed job', () => {
|
|
40
|
+
let state = evolve(initialState(), {
|
|
41
|
+
type: 'GraphSubmitted',
|
|
42
|
+
data: {
|
|
43
|
+
graphId: 'g1',
|
|
44
|
+
jobs: [
|
|
45
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
46
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
47
|
+
{ id: 'c', dependsOn: [], target: 'lint', payload: {} },
|
|
48
|
+
],
|
|
49
|
+
failurePolicy: 'skip-dependents',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
state = evolve(state, {
|
|
53
|
+
type: 'JobDispatched',
|
|
54
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
55
|
+
});
|
|
56
|
+
state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
|
|
57
|
+
|
|
58
|
+
const events = applyPolicy(state, 'a');
|
|
59
|
+
|
|
60
|
+
expect(events).toEqual([{ type: 'JobSkipped', data: { jobId: 'b', reason: 'dependency failed' } }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('continue policy returns no skip events', () => {
|
|
64
|
+
let state = evolve(initialState(), {
|
|
65
|
+
type: 'GraphSubmitted',
|
|
66
|
+
data: {
|
|
67
|
+
graphId: 'g1',
|
|
68
|
+
jobs: [
|
|
69
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
70
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
71
|
+
],
|
|
72
|
+
failurePolicy: 'continue',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
state = evolve(state, {
|
|
76
|
+
type: 'JobDispatched',
|
|
77
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
78
|
+
});
|
|
79
|
+
state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
|
|
80
|
+
|
|
81
|
+
const events = applyPolicy(state, 'a');
|
|
82
|
+
|
|
83
|
+
expect(events).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GraphState, JobGraphEvent } from './evolve';
|
|
2
|
+
import { getTransitiveDependents } from './evolve';
|
|
3
|
+
|
|
4
|
+
export function applyPolicy(state: GraphState, failedJobId: string): JobGraphEvent[] {
|
|
5
|
+
if (state.status !== 'processing') return [];
|
|
6
|
+
|
|
7
|
+
switch (state.failurePolicy) {
|
|
8
|
+
case 'continue':
|
|
9
|
+
return [];
|
|
10
|
+
case 'skip-dependents':
|
|
11
|
+
return getTransitiveDependents(state, failedJobId).map(
|
|
12
|
+
(id): JobGraphEvent => ({
|
|
13
|
+
type: 'JobSkipped',
|
|
14
|
+
data: { jobId: id, reason: 'dependency failed' },
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
case 'halt': {
|
|
18
|
+
const events: JobGraphEvent[] = [];
|
|
19
|
+
for (const [id, job] of state.jobs) {
|
|
20
|
+
if (job.status === 'pending') {
|
|
21
|
+
events.push({ type: 'JobSkipped', data: { jobId: id, reason: 'halt policy' } });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return events;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createMessageBus } from '@auto-engineer/message-bus';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { commandHandler } from './process-job-graph';
|
|
4
|
+
|
|
5
|
+
describe('ProcessJobGraph command handler', () => {
|
|
6
|
+
it('returns graph.dispatching with dispatched jobs when given valid graph and messageBus', async () => {
|
|
7
|
+
const messageBus = createMessageBus();
|
|
8
|
+
|
|
9
|
+
const result = await commandHandler.handle(
|
|
10
|
+
{
|
|
11
|
+
type: 'ProcessJobGraph',
|
|
12
|
+
data: {
|
|
13
|
+
graphId: 'g1',
|
|
14
|
+
jobs: [
|
|
15
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } },
|
|
16
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
17
|
+
],
|
|
18
|
+
failurePolicy: 'halt',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{ messageBus },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(result).toEqual({
|
|
25
|
+
type: 'graph.dispatching',
|
|
26
|
+
data: {
|
|
27
|
+
graphId: 'g1',
|
|
28
|
+
dispatchedJobs: [{ jobId: 'a', target: 'build', payload: { src: './app' }, correlationId: 'graph:g1:a' }],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns graph.failed when messageBus is not available in context', async () => {
|
|
34
|
+
const result = await commandHandler.handle({
|
|
35
|
+
type: 'ProcessJobGraph',
|
|
36
|
+
data: {
|
|
37
|
+
graphId: 'g1',
|
|
38
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
39
|
+
failurePolicy: 'halt',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
type: 'graph.failed',
|
|
45
|
+
data: { graphId: 'g1', reason: 'messageBus not available in context' },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('dispatches target commands via ctx.sendCommand when provided', async () => {
|
|
50
|
+
const messageBus = createMessageBus();
|
|
51
|
+
const dispatched: Array<{ type: string; data: unknown; correlationId: string }> = [];
|
|
52
|
+
const sendCommand = async (type: string, data: unknown, correlationId?: string) => {
|
|
53
|
+
dispatched.push({ type, data, correlationId: correlationId! });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
await commandHandler.handle(
|
|
57
|
+
{
|
|
58
|
+
type: 'ProcessJobGraph',
|
|
59
|
+
data: {
|
|
60
|
+
graphId: 'g1',
|
|
61
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: { src: './app' } }],
|
|
62
|
+
failurePolicy: 'halt',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{ messageBus, sendCommand },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
69
|
+
|
|
70
|
+
expect(dispatched).toEqual([{ type: 'build', data: { src: './app' }, correlationId: 'graph:g1:a' }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns graph.failed for invalid graph', async () => {
|
|
74
|
+
const messageBus = createMessageBus();
|
|
75
|
+
|
|
76
|
+
const result = await commandHandler.handle(
|
|
77
|
+
{
|
|
78
|
+
type: 'ProcessJobGraph',
|
|
79
|
+
data: {
|
|
80
|
+
graphId: 'g1',
|
|
81
|
+
jobs: [],
|
|
82
|
+
failurePolicy: 'halt',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{ messageBus },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
type: 'graph.failed',
|
|
90
|
+
data: { graphId: 'g1', reason: 'Graph must contain at least one job' },
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Command, Event, MessageBus } from '@auto-engineer/message-bus';
|
|
2
|
+
import type { FailurePolicy } from '../evolve';
|
|
3
|
+
import type { DispatchFn } from '../graph-processor';
|
|
4
|
+
import { createGraphProcessor, type ProcessGraphCommand } from '../graph-processor';
|
|
5
|
+
import type { Job } from '../graph-validator';
|
|
6
|
+
|
|
7
|
+
interface ContextWithMessageBus {
|
|
8
|
+
messageBus: MessageBus;
|
|
9
|
+
sendCommand?: (type: string, data: unknown, correlationId?: string) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isContextWithMessageBus(context: unknown): context is ContextWithMessageBus {
|
|
13
|
+
return (
|
|
14
|
+
context !== null &&
|
|
15
|
+
context !== undefined &&
|
|
16
|
+
typeof context === 'object' &&
|
|
17
|
+
'messageBus' in context &&
|
|
18
|
+
context.messageBus !== null &&
|
|
19
|
+
context.messageBus !== undefined &&
|
|
20
|
+
typeof context.messageBus === 'object'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildDispatch(context: ContextWithMessageBus): DispatchFn | undefined {
|
|
25
|
+
if (typeof context.sendCommand !== 'function') return undefined;
|
|
26
|
+
const ctxSendCommand = context.sendCommand;
|
|
27
|
+
return (cmd) => ctxSendCommand(cmd.type, cmd.data, cmd.correlationId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toProcessGraphCommand(command: Command): ProcessGraphCommand {
|
|
31
|
+
const data = command.data as { graphId: string; jobs: Job[]; failurePolicy: FailurePolicy };
|
|
32
|
+
return { type: command.type, data };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const commandHandler = {
|
|
36
|
+
name: 'ProcessJobGraph',
|
|
37
|
+
displayName: 'Process Job Graph',
|
|
38
|
+
alias: 'process:job-graph',
|
|
39
|
+
description: 'Process a directed acyclic graph of jobs with dependency tracking and failure policies',
|
|
40
|
+
category: 'orchestration',
|
|
41
|
+
icon: 'git-branch',
|
|
42
|
+
fields: {
|
|
43
|
+
graphId: {
|
|
44
|
+
description: 'Unique identifier for the graph',
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
jobs: {
|
|
48
|
+
description: 'Array of jobs with dependencies',
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
51
|
+
failurePolicy: {
|
|
52
|
+
description: 'Policy for handling job failures: halt, skip-dependents, or continue',
|
|
53
|
+
required: true,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
examples: [
|
|
57
|
+
'$ auto process:job-graph --graphId=g1 --jobs=\'[{"id":"a","dependsOn":[],"target":"build","payload":{}}]\' --failurePolicy=halt',
|
|
58
|
+
],
|
|
59
|
+
events: [
|
|
60
|
+
{ name: 'graph.dispatching', displayName: 'Graph Dispatching' },
|
|
61
|
+
{ name: 'graph.failed', displayName: 'Graph Failed' },
|
|
62
|
+
{ name: 'graph.completed', displayName: 'Graph Completed' },
|
|
63
|
+
],
|
|
64
|
+
handle: async (command: Command, context?: unknown): Promise<Event | Event[]> => {
|
|
65
|
+
const { graphId } = command.data;
|
|
66
|
+
|
|
67
|
+
if (!isContextWithMessageBus(context)) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'graph.failed',
|
|
70
|
+
data: { graphId, reason: 'messageBus not available in context' },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const dispatch = buildDispatch(context);
|
|
75
|
+
const processor = createGraphProcessor(context.messageBus, dispatch ? { dispatch } : undefined);
|
|
76
|
+
return processor.submit(toProcessGraphCommand(command));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default commandHandler;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evolve, getReadyJobs, getTransitiveDependents, initialState, isGraphComplete } from './evolve';
|
|
3
|
+
|
|
4
|
+
describe('evolve', () => {
|
|
5
|
+
it('ignores job events before graph submission', () => {
|
|
6
|
+
const state = evolve(initialState(), { type: 'JobSucceeded', data: { jobId: 'a' } });
|
|
7
|
+
|
|
8
|
+
expect(getReadyJobs(state)).toEqual([]);
|
|
9
|
+
expect(isGraphComplete(state)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('ignores job events for unknown job IDs', () => {
|
|
13
|
+
const state = evolve(initialState(), {
|
|
14
|
+
type: 'GraphSubmitted',
|
|
15
|
+
data: {
|
|
16
|
+
graphId: 'g1',
|
|
17
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
18
|
+
failurePolicy: 'halt',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const after = evolve(state, { type: 'JobSucceeded', data: { jobId: 'unknown' } });
|
|
22
|
+
|
|
23
|
+
expect(getReadyJobs(after)).toEqual(['a']);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('getReadyJobs', () => {
|
|
28
|
+
it('returns empty array before graph submission', () => {
|
|
29
|
+
expect(getReadyJobs(initialState())).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns root jobs after graph submission', () => {
|
|
33
|
+
const state = evolve(initialState(), {
|
|
34
|
+
type: 'GraphSubmitted',
|
|
35
|
+
data: {
|
|
36
|
+
graphId: 'g1',
|
|
37
|
+
jobs: [
|
|
38
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
39
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
40
|
+
],
|
|
41
|
+
failurePolicy: 'halt',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(getReadyJobs(state)).toEqual(['a']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('unlocks dependent jobs after dependency succeeds', () => {
|
|
49
|
+
let state = evolve(initialState(), {
|
|
50
|
+
type: 'GraphSubmitted',
|
|
51
|
+
data: {
|
|
52
|
+
graphId: 'g1',
|
|
53
|
+
jobs: [
|
|
54
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
55
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
56
|
+
],
|
|
57
|
+
failurePolicy: 'halt',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
state = evolve(state, {
|
|
61
|
+
type: 'JobDispatched',
|
|
62
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
63
|
+
});
|
|
64
|
+
state = evolve(state, { type: 'JobSucceeded', data: { jobId: 'a' } });
|
|
65
|
+
|
|
66
|
+
expect(getReadyJobs(state)).toEqual(['b']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('unlocks dependents of failed job under continue policy', () => {
|
|
70
|
+
let state = evolve(initialState(), {
|
|
71
|
+
type: 'GraphSubmitted',
|
|
72
|
+
data: {
|
|
73
|
+
graphId: 'g1',
|
|
74
|
+
jobs: [
|
|
75
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
76
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
77
|
+
],
|
|
78
|
+
failurePolicy: 'continue',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
state = evolve(state, {
|
|
82
|
+
type: 'JobDispatched',
|
|
83
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
84
|
+
});
|
|
85
|
+
state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
|
|
86
|
+
|
|
87
|
+
expect(getReadyJobs(state)).toEqual(['b']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not unlock dependents of dispatched job under continue policy', () => {
|
|
91
|
+
let state = evolve(initialState(), {
|
|
92
|
+
type: 'GraphSubmitted',
|
|
93
|
+
data: {
|
|
94
|
+
graphId: 'g1',
|
|
95
|
+
jobs: [
|
|
96
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
97
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
98
|
+
],
|
|
99
|
+
failurePolicy: 'continue',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
state = evolve(state, {
|
|
103
|
+
type: 'JobDispatched',
|
|
104
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(getReadyJobs(state)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getTransitiveDependents', () => {
|
|
112
|
+
it('returns empty array before graph submission', () => {
|
|
113
|
+
expect(getTransitiveDependents(initialState(), 'a')).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns direct and transitive dependents of a job', () => {
|
|
117
|
+
const state = evolve(initialState(), {
|
|
118
|
+
type: 'GraphSubmitted',
|
|
119
|
+
data: {
|
|
120
|
+
graphId: 'g1',
|
|
121
|
+
jobs: [
|
|
122
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
123
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
124
|
+
{ id: 'c', dependsOn: ['b'], target: 'deploy', payload: {} },
|
|
125
|
+
],
|
|
126
|
+
failurePolicy: 'halt',
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(getTransitiveDependents(state, 'a').sort()).toEqual(['b', 'c']);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('isGraphComplete', () => {
|
|
135
|
+
it('returns false before graph submission', () => {
|
|
136
|
+
expect(isGraphComplete(initialState())).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns false when some jobs are still pending', () => {
|
|
140
|
+
let state = evolve(initialState(), {
|
|
141
|
+
type: 'GraphSubmitted',
|
|
142
|
+
data: {
|
|
143
|
+
graphId: 'g1',
|
|
144
|
+
jobs: [
|
|
145
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
146
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
147
|
+
],
|
|
148
|
+
failurePolicy: 'halt',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
state = evolve(state, {
|
|
152
|
+
type: 'JobDispatched',
|
|
153
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
154
|
+
});
|
|
155
|
+
state = evolve(state, { type: 'JobSucceeded', data: { jobId: 'a' } });
|
|
156
|
+
|
|
157
|
+
expect(isGraphComplete(state)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns true when a job has been skipped', () => {
|
|
161
|
+
let state = evolve(initialState(), {
|
|
162
|
+
type: 'GraphSubmitted',
|
|
163
|
+
data: {
|
|
164
|
+
graphId: 'g1',
|
|
165
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
166
|
+
failurePolicy: 'halt',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
state = evolve(state, { type: 'JobSkipped', data: { jobId: 'a', reason: 'dependency failed' } });
|
|
170
|
+
|
|
171
|
+
expect(isGraphComplete(state)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns true when a job has timed out', () => {
|
|
175
|
+
let state = evolve(initialState(), {
|
|
176
|
+
type: 'GraphSubmitted',
|
|
177
|
+
data: {
|
|
178
|
+
graphId: 'g1',
|
|
179
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
180
|
+
failurePolicy: 'halt',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
state = evolve(state, {
|
|
184
|
+
type: 'JobDispatched',
|
|
185
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
186
|
+
});
|
|
187
|
+
state = evolve(state, { type: 'JobTimedOut', data: { jobId: 'a', timeoutMs: 5000 } });
|
|
188
|
+
|
|
189
|
+
expect(isGraphComplete(state)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('returns true when a job has failed', () => {
|
|
193
|
+
let state = evolve(initialState(), {
|
|
194
|
+
type: 'GraphSubmitted',
|
|
195
|
+
data: {
|
|
196
|
+
graphId: 'g1',
|
|
197
|
+
jobs: [{ id: 'a', dependsOn: [], target: 'build', payload: {} }],
|
|
198
|
+
failurePolicy: 'halt',
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
state = evolve(state, {
|
|
202
|
+
type: 'JobDispatched',
|
|
203
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
204
|
+
});
|
|
205
|
+
state = evolve(state, { type: 'JobFailed', data: { jobId: 'a', error: 'build error' } });
|
|
206
|
+
|
|
207
|
+
expect(isGraphComplete(state)).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns true when all jobs have succeeded', () => {
|
|
211
|
+
let state = evolve(initialState(), {
|
|
212
|
+
type: 'GraphSubmitted',
|
|
213
|
+
data: {
|
|
214
|
+
graphId: 'g1',
|
|
215
|
+
jobs: [
|
|
216
|
+
{ id: 'a', dependsOn: [], target: 'build', payload: {} },
|
|
217
|
+
{ id: 'b', dependsOn: ['a'], target: 'test', payload: {} },
|
|
218
|
+
],
|
|
219
|
+
failurePolicy: 'halt',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
state = evolve(state, {
|
|
223
|
+
type: 'JobDispatched',
|
|
224
|
+
data: { jobId: 'a', target: 'build', correlationId: 'graph:g1:a' },
|
|
225
|
+
});
|
|
226
|
+
state = evolve(state, { type: 'JobSucceeded', data: { jobId: 'a' } });
|
|
227
|
+
state = evolve(state, {
|
|
228
|
+
type: 'JobDispatched',
|
|
229
|
+
data: { jobId: 'b', target: 'test', correlationId: 'graph:g1:b' },
|
|
230
|
+
});
|
|
231
|
+
state = evolve(state, { type: 'JobSucceeded', data: { jobId: 'b' } });
|
|
232
|
+
|
|
233
|
+
expect(isGraphComplete(state)).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
package/src/evolve.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Job } from './graph-validator';
|
|
2
|
+
|
|
3
|
+
export type JobStatus = 'pending' | 'dispatched' | 'succeeded' | 'failed' | 'skipped' | 'timed-out';
|
|
4
|
+
export type FailurePolicy = 'halt' | 'skip-dependents' | 'continue';
|
|
5
|
+
|
|
6
|
+
interface JobState {
|
|
7
|
+
jobId: string;
|
|
8
|
+
status: JobStatus;
|
|
9
|
+
dependsOn: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type GraphSubmitted = {
|
|
13
|
+
type: 'GraphSubmitted';
|
|
14
|
+
data: { graphId: string; jobs: readonly Job[]; failurePolicy: FailurePolicy };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type JobDispatched = {
|
|
18
|
+
type: 'JobDispatched';
|
|
19
|
+
data: { jobId: string; target: string; correlationId: string };
|
|
20
|
+
};
|
|
21
|
+
type JobSucceeded = { type: 'JobSucceeded'; data: { jobId: string; result?: unknown } };
|
|
22
|
+
type JobFailed = { type: 'JobFailed'; data: { jobId: string; error: string } };
|
|
23
|
+
type JobSkipped = { type: 'JobSkipped'; data: { jobId: string; reason: string } };
|
|
24
|
+
type JobTimedOut = { type: 'JobTimedOut'; data: { jobId: string; timeoutMs: number } };
|
|
25
|
+
|
|
26
|
+
export type JobGraphEvent = GraphSubmitted | JobDispatched | JobSucceeded | JobFailed | JobSkipped | JobTimedOut;
|
|
27
|
+
|
|
28
|
+
type PendingGraphState = { status: 'pending' };
|
|
29
|
+
type ProcessingGraphState = {
|
|
30
|
+
status: 'processing';
|
|
31
|
+
graphId: string;
|
|
32
|
+
failurePolicy: FailurePolicy;
|
|
33
|
+
jobs: Map<string, JobState>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type GraphState = PendingGraphState | ProcessingGraphState;
|
|
37
|
+
|
|
38
|
+
export function initialState(): GraphState {
|
|
39
|
+
return { status: 'pending' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function evolve(state: GraphState, event: JobGraphEvent): GraphState {
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'GraphSubmitted':
|
|
45
|
+
return {
|
|
46
|
+
status: 'processing',
|
|
47
|
+
graphId: event.data.graphId,
|
|
48
|
+
failurePolicy: event.data.failurePolicy,
|
|
49
|
+
jobs: new Map(
|
|
50
|
+
event.data.jobs.map((job): [string, JobState] => [
|
|
51
|
+
job.id,
|
|
52
|
+
{ jobId: job.id, status: 'pending', dependsOn: job.dependsOn },
|
|
53
|
+
]),
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
case 'JobDispatched':
|
|
57
|
+
return updateJobStatus(state, event.data.jobId, 'dispatched');
|
|
58
|
+
case 'JobSucceeded':
|
|
59
|
+
return updateJobStatus(state, event.data.jobId, 'succeeded');
|
|
60
|
+
case 'JobFailed':
|
|
61
|
+
return updateJobStatus(state, event.data.jobId, 'failed');
|
|
62
|
+
case 'JobSkipped':
|
|
63
|
+
return updateJobStatus(state, event.data.jobId, 'skipped');
|
|
64
|
+
case 'JobTimedOut':
|
|
65
|
+
return updateJobStatus(state, event.data.jobId, 'timed-out');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function updateJobStatus(state: GraphState, jobId: string, status: JobStatus): GraphState {
|
|
70
|
+
if (state.status !== 'processing') return state;
|
|
71
|
+
const jobs = new Map(state.jobs);
|
|
72
|
+
const existing = jobs.get(jobId);
|
|
73
|
+
if (existing === undefined) return state;
|
|
74
|
+
jobs.set(jobId, { ...existing, status });
|
|
75
|
+
return { ...state, jobs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TERMINAL_STATUSES: ReadonlySet<JobStatus> = new Set(['succeeded', 'failed', 'skipped', 'timed-out']);
|
|
79
|
+
|
|
80
|
+
export function isGraphComplete(state: GraphState): boolean {
|
|
81
|
+
if (state.status !== 'processing') return false;
|
|
82
|
+
for (const job of state.jobs.values()) {
|
|
83
|
+
if (!TERMINAL_STATUSES.has(job.status)) return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getTransitiveDependents(state: GraphState, jobId: string): string[] {
|
|
89
|
+
if (state.status !== 'processing') return [];
|
|
90
|
+
const dependents: Set<string> = new Set();
|
|
91
|
+
const queue = [jobId];
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const current = queue.pop()!;
|
|
94
|
+
for (const [id, job] of state.jobs) {
|
|
95
|
+
if (!dependents.has(id) && job.dependsOn.includes(current)) {
|
|
96
|
+
dependents.add(id);
|
|
97
|
+
queue.push(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...dependents];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getReadyJobs(state: GraphState): string[] {
|
|
105
|
+
if (state.status !== 'processing') return [];
|
|
106
|
+
const ready: string[] = [];
|
|
107
|
+
for (const [id, job] of state.jobs) {
|
|
108
|
+
if (job.status !== 'pending') continue;
|
|
109
|
+
const allDepsResolved = job.dependsOn.every((dep) => {
|
|
110
|
+
const depJob = state.jobs.get(dep);
|
|
111
|
+
return (
|
|
112
|
+
depJob !== undefined &&
|
|
113
|
+
(depJob.status === 'succeeded' || (state.failurePolicy === 'continue' && TERMINAL_STATUSES.has(depJob.status)))
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
if (allDepsResolved) {
|
|
117
|
+
ready.push(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return ready;
|
|
121
|
+
}
|