@auto-engineer/pipeline 1.65.0 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +135 -0
- package/dist/src/builder/define-v2.d.ts +101 -0
- package/dist/src/builder/define-v2.d.ts.map +1 -0
- package/dist/src/builder/define-v2.js +209 -0
- package/dist/src/builder/define-v2.js.map +1 -0
- package/dist/src/engine/command-dispatcher.d.ts +31 -0
- package/dist/src/engine/command-dispatcher.d.ts.map +1 -0
- package/dist/src/engine/command-dispatcher.js +26 -0
- package/dist/src/engine/command-dispatcher.js.map +1 -0
- package/dist/src/engine/event-router.d.ts +21 -0
- package/dist/src/engine/event-router.d.ts.map +1 -0
- package/dist/src/engine/event-router.js +22 -0
- package/dist/src/engine/event-router.js.map +1 -0
- package/dist/src/engine/index.d.ts +15 -0
- package/dist/src/engine/index.d.ts.map +1 -0
- package/dist/src/engine/index.js +15 -0
- package/dist/src/engine/index.js.map +1 -0
- package/dist/src/engine/pipeline-engine.d.ts +37 -0
- package/dist/src/engine/pipeline-engine.d.ts.map +1 -0
- package/dist/src/engine/pipeline-engine.js +53 -0
- package/dist/src/engine/pipeline-engine.js.map +1 -0
- package/dist/src/engine/projections/item-status.d.ts +9 -0
- package/dist/src/engine/projections/item-status.d.ts.map +1 -0
- package/dist/src/engine/projections/item-status.js +9 -0
- package/dist/src/engine/projections/item-status.js.map +1 -0
- package/dist/src/engine/projections/latest-run.d.ts +9 -0
- package/dist/src/engine/projections/latest-run.d.ts.map +1 -0
- package/dist/src/engine/projections/latest-run.js +9 -0
- package/dist/src/engine/projections/latest-run.js.map +1 -0
- package/dist/src/engine/projections/message-log.d.ts +9 -0
- package/dist/src/engine/projections/message-log.d.ts.map +1 -0
- package/dist/src/engine/projections/message-log.js +10 -0
- package/dist/src/engine/projections/message-log.js.map +1 -0
- package/dist/src/engine/projections/node-status.d.ts +9 -0
- package/dist/src/engine/projections/node-status.d.ts.map +1 -0
- package/dist/src/engine/projections/node-status.js +9 -0
- package/dist/src/engine/projections/node-status.js.map +1 -0
- package/dist/src/engine/projections/stats.d.ts +9 -0
- package/dist/src/engine/projections/stats.d.ts.map +1 -0
- package/dist/src/engine/projections/stats.js +9 -0
- package/dist/src/engine/projections/stats.js.map +1 -0
- package/dist/src/engine/sqlite-consumer.d.ts +11 -0
- package/dist/src/engine/sqlite-consumer.d.ts.map +1 -0
- package/dist/src/engine/sqlite-consumer.js +27 -0
- package/dist/src/engine/sqlite-consumer.js.map +1 -0
- package/dist/src/engine/sqlite-store.d.ts +10 -0
- package/dist/src/engine/sqlite-store.d.ts.map +1 -0
- package/dist/src/engine/sqlite-store.js +14 -0
- package/dist/src/engine/sqlite-store.js.map +1 -0
- package/dist/src/engine/workflow-processor.d.ts +20 -0
- package/dist/src/engine/workflow-processor.d.ts.map +1 -0
- package/dist/src/engine/workflow-processor.js +36 -0
- package/dist/src/engine/workflow-processor.js.map +1 -0
- package/dist/src/engine/workflows/await-workflow.d.ts +33 -0
- package/dist/src/engine/workflows/await-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/await-workflow.js +45 -0
- package/dist/src/engine/workflows/await-workflow.js.map +1 -0
- package/dist/src/engine/workflows/phased-workflow.d.ts +64 -0
- package/dist/src/engine/workflows/phased-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/phased-workflow.js +103 -0
- package/dist/src/engine/workflows/phased-workflow.js.map +1 -0
- package/dist/src/engine/workflows/settled-workflow.d.ts +62 -0
- package/dist/src/engine/workflows/settled-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/settled-workflow.js +92 -0
- package/dist/src/engine/workflows/settled-workflow.js.map +1 -0
- package/dist/src/graph/types.d.ts +1 -1
- package/dist/src/graph/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/server/pipeline-server-v2.d.ts +48 -0
- package/dist/src/server/pipeline-server-v2.d.ts.map +1 -0
- package/dist/src/server/pipeline-server-v2.js +61 -0
- package/dist/src/server/pipeline-server-v2.js.map +1 -0
- package/dist/src/server/pipeline-server.d.ts +5 -1
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.js +71 -10
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +13 -0
- package/package.json +3 -3
- package/src/builder/define-v2.specs.ts +236 -0
- package/src/builder/define-v2.ts +351 -0
- package/src/engine/command-dispatcher.specs.ts +62 -0
- package/src/engine/command-dispatcher.ts +46 -0
- package/src/engine/event-router.specs.ts +75 -0
- package/src/engine/event-router.ts +36 -0
- package/src/engine/index.ts +39 -0
- package/src/engine/pipeline-engine-e2e.specs.ts +776 -0
- package/src/engine/pipeline-engine.integration.specs.ts +126 -0
- package/src/engine/pipeline-engine.specs.ts +70 -0
- package/src/engine/pipeline-engine.ts +82 -0
- package/src/engine/projections/item-status.ts +11 -0
- package/src/engine/projections/latest-run.ts +10 -0
- package/src/engine/projections/message-log.ts +11 -0
- package/src/engine/projections/node-status.ts +10 -0
- package/src/engine/projections/projections.specs.ts +176 -0
- package/src/engine/projections/stats.ts +10 -0
- package/src/engine/sqlite-consumer.specs.ts +42 -0
- package/src/engine/sqlite-consumer.ts +34 -0
- package/src/engine/sqlite-store.specs.ts +46 -0
- package/src/engine/sqlite-store.ts +21 -0
- package/src/engine/workflow-processor.specs.ts +37 -0
- package/src/engine/workflow-processor.ts +57 -0
- package/src/engine/workflows/await-workflow.specs.ts +104 -0
- package/src/engine/workflows/await-workflow.ts +66 -0
- package/src/engine/workflows/phased-workflow.specs.ts +383 -0
- package/src/engine/workflows/phased-workflow.ts +153 -0
- package/src/engine/workflows/settled-workflow.specs.ts +364 -0
- package/src/engine/workflows/settled-workflow.ts +139 -0
- package/src/graph/types.ts +1 -1
- package/src/index.ts +2 -0
- package/src/server/pipeline-server-v2.specs.ts +91 -0
- package/src/server/pipeline-server-v2.ts +70 -0
- package/src/server/pipeline-server.specs.ts +327 -134
- package/src/server/pipeline-server.ts +77 -11
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createWorkflowProcessor } from './workflow-processor.js';
|
|
2
|
+
import { createSettledWorkflow } from './workflows/settled-workflow.js';
|
|
3
|
+
|
|
4
|
+
describe('WorkflowProcessor', () => {
|
|
5
|
+
it('processes input event through workflow decide/evolve cycle', () => {
|
|
6
|
+
const processor = createWorkflowProcessor();
|
|
7
|
+
processor.register({
|
|
8
|
+
id: 'test-settled',
|
|
9
|
+
workflow: createSettledWorkflow({ commandTypes: ['A', 'B'] }),
|
|
10
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const startResult = processor.process({
|
|
14
|
+
type: 'StartSettled',
|
|
15
|
+
data: { correlationId: 'c1', commandTypes: ['A', 'B'] },
|
|
16
|
+
});
|
|
17
|
+
expect(startResult).toEqual([]);
|
|
18
|
+
|
|
19
|
+
const completeA = processor.process({
|
|
20
|
+
type: 'CommandCompleted',
|
|
21
|
+
data: { commandType: 'A', result: 'success', event: {} },
|
|
22
|
+
});
|
|
23
|
+
expect(completeA).toEqual([]);
|
|
24
|
+
|
|
25
|
+
const completeB = processor.process({
|
|
26
|
+
type: 'CommandCompleted',
|
|
27
|
+
data: { commandType: 'B', result: 'success', event: {} },
|
|
28
|
+
});
|
|
29
|
+
expect(completeB).toEqual([expect.objectContaining({ type: 'AllSettled' })]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns empty for unregistered event types', () => {
|
|
33
|
+
const processor = createWorkflowProcessor();
|
|
34
|
+
const result = processor.process({ type: 'Unknown', data: {} });
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
type Event = { type: string; data: Record<string, unknown> };
|
|
2
|
+
|
|
3
|
+
type WorkflowDef = {
|
|
4
|
+
decide: (input: Event, state: unknown) => Event | Event[];
|
|
5
|
+
evolve: (state: unknown, event: Event) => unknown;
|
|
6
|
+
initialState: () => unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type WorkflowRegistration = {
|
|
10
|
+
id: string;
|
|
11
|
+
workflow: WorkflowDef;
|
|
12
|
+
inputEvents: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createWorkflowProcessor() {
|
|
16
|
+
const registrations = new Map<string, WorkflowRegistration>();
|
|
17
|
+
const eventsByWorkflow = new Map<string, Event[]>();
|
|
18
|
+
|
|
19
|
+
function rebuildState(reg: WorkflowRegistration, events: Event[]): unknown {
|
|
20
|
+
let state = reg.workflow.initialState();
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
state = reg.workflow.evolve(state, event);
|
|
23
|
+
}
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
register(registration: WorkflowRegistration): void {
|
|
29
|
+
registrations.set(registration.id, registration);
|
|
30
|
+
eventsByWorkflow.set(registration.id, []);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
process(event: Event): Event[] {
|
|
34
|
+
const outputs: Event[] = [];
|
|
35
|
+
|
|
36
|
+
for (const [id, reg] of registrations) {
|
|
37
|
+
if (!reg.inputEvents.includes(event.type)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const history = eventsByWorkflow.get(id)!;
|
|
42
|
+
history.push(event);
|
|
43
|
+
|
|
44
|
+
const state = rebuildState(reg, history);
|
|
45
|
+
const result = reg.workflow.decide(event, state);
|
|
46
|
+
const resultEvents = Array.isArray(result) ? result : [result];
|
|
47
|
+
|
|
48
|
+
for (const outputEvent of resultEvents) {
|
|
49
|
+
history.push(outputEvent);
|
|
50
|
+
outputs.push(outputEvent);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return outputs;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type AwaitInput, type AwaitState, createAwaitWorkflow, decide, evolve, initialState } from './await-workflow';
|
|
2
|
+
|
|
3
|
+
describe('await workflow', () => {
|
|
4
|
+
describe('evolve', () => {
|
|
5
|
+
it('transitions from idle to waiting on StartAwait', () => {
|
|
6
|
+
const state = initialState();
|
|
7
|
+
const event = { type: 'StartAwait' as const, data: { correlationId: 'c1', keys: ['a', 'b', 'c'] } };
|
|
8
|
+
const result = evolve(state, event);
|
|
9
|
+
expect(result).toEqual({
|
|
10
|
+
status: 'waiting',
|
|
11
|
+
pendingKeys: ['a', 'b', 'c'],
|
|
12
|
+
results: {},
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('removes key from pending and stores result on KeyCompleted', () => {
|
|
17
|
+
const state: AwaitState = {
|
|
18
|
+
status: 'waiting',
|
|
19
|
+
pendingKeys: ['a', 'b'],
|
|
20
|
+
results: {},
|
|
21
|
+
};
|
|
22
|
+
const event = { type: 'KeyCompleted' as const, data: { key: 'a', result: { value: 42 } } };
|
|
23
|
+
const result = evolve(state, event);
|
|
24
|
+
expect(result).toEqual({
|
|
25
|
+
status: 'waiting',
|
|
26
|
+
pendingKeys: ['b'],
|
|
27
|
+
results: { a: { value: 42 } },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('ignores KeyCompleted for unknown keys', () => {
|
|
32
|
+
const state: AwaitState = {
|
|
33
|
+
status: 'waiting',
|
|
34
|
+
pendingKeys: ['a'],
|
|
35
|
+
results: {},
|
|
36
|
+
};
|
|
37
|
+
const event = { type: 'KeyCompleted' as const, data: { key: 'z', result: { value: 1 } } };
|
|
38
|
+
const result = evolve(state, event);
|
|
39
|
+
expect(result).toEqual(state);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('decide', () => {
|
|
44
|
+
it('returns empty array when keys still pending', () => {
|
|
45
|
+
const state: AwaitState = {
|
|
46
|
+
status: 'waiting',
|
|
47
|
+
pendingKeys: ['b', 'c'],
|
|
48
|
+
results: { a: { value: 1 } },
|
|
49
|
+
};
|
|
50
|
+
const input: AwaitInput = {
|
|
51
|
+
type: 'KeyCompleted',
|
|
52
|
+
data: { key: 'a', result: { value: 1 } },
|
|
53
|
+
};
|
|
54
|
+
const result = decide(input, state);
|
|
55
|
+
expect(result).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns AwaitCompleted when all keys resolved', () => {
|
|
59
|
+
const state: AwaitState = {
|
|
60
|
+
status: 'waiting',
|
|
61
|
+
pendingKeys: [],
|
|
62
|
+
results: {
|
|
63
|
+
a: { value: 1 },
|
|
64
|
+
b: { value: 2 },
|
|
65
|
+
c: { value: 3 },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const input: AwaitInput = {
|
|
69
|
+
type: 'KeyCompleted',
|
|
70
|
+
data: { key: 'c', result: { value: 3 } },
|
|
71
|
+
};
|
|
72
|
+
const result = decide(input, state);
|
|
73
|
+
expect(result).toEqual({
|
|
74
|
+
type: 'AwaitCompleted',
|
|
75
|
+
data: {
|
|
76
|
+
results: {
|
|
77
|
+
a: { value: 1 },
|
|
78
|
+
b: { value: 2 },
|
|
79
|
+
c: { value: 3 },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns empty array when status is idle', () => {
|
|
86
|
+
const state = initialState();
|
|
87
|
+
const input: AwaitInput = {
|
|
88
|
+
type: 'StartAwait',
|
|
89
|
+
data: { correlationId: 'c1', keys: ['a'] },
|
|
90
|
+
};
|
|
91
|
+
const result = decide(input, state);
|
|
92
|
+
expect(result).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('createAwaitWorkflow', () => {
|
|
97
|
+
it('factory produces workflow with decide, evolve, initialState', () => {
|
|
98
|
+
const workflow = createAwaitWorkflow();
|
|
99
|
+
expect(workflow.decide).toBe(decide);
|
|
100
|
+
expect(workflow.evolve).toBe(evolve);
|
|
101
|
+
expect(workflow.initialState).toBe(initialState);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type AwaitInput =
|
|
2
|
+
| { type: 'StartAwait'; data: { correlationId: string; keys: string[] } }
|
|
3
|
+
| { type: 'KeyCompleted'; data: { key: string; result: Record<string, unknown> } };
|
|
4
|
+
|
|
5
|
+
export type AwaitOutput = {
|
|
6
|
+
type: 'AwaitCompleted';
|
|
7
|
+
data: { results: Record<string, Record<string, unknown>> };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type AwaitState = {
|
|
11
|
+
status: 'idle' | 'waiting' | 'completed';
|
|
12
|
+
pendingKeys: string[];
|
|
13
|
+
results: Record<string, Record<string, unknown>>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function initialState(): AwaitState {
|
|
17
|
+
return {
|
|
18
|
+
status: 'idle',
|
|
19
|
+
pendingKeys: [],
|
|
20
|
+
results: {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function decide(input: AwaitInput, state: AwaitState): AwaitOutput | AwaitOutput[] {
|
|
25
|
+
if (state.status !== 'waiting' || state.pendingKeys.length > 0) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
type: 'AwaitCompleted',
|
|
30
|
+
data: { results: state.results },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createAwaitWorkflow(): {
|
|
35
|
+
decide: typeof decide;
|
|
36
|
+
evolve: typeof evolve;
|
|
37
|
+
initialState: typeof initialState;
|
|
38
|
+
} {
|
|
39
|
+
return { decide, evolve, initialState };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function evolve(state: AwaitState, event: AwaitInput | AwaitOutput): AwaitState {
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case 'StartAwait':
|
|
45
|
+
return {
|
|
46
|
+
...state,
|
|
47
|
+
status: 'waiting',
|
|
48
|
+
pendingKeys: event.data.keys,
|
|
49
|
+
results: {},
|
|
50
|
+
};
|
|
51
|
+
case 'KeyCompleted': {
|
|
52
|
+
if (!state.pendingKeys.includes(event.data.key)) {
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
pendingKeys: state.pendingKeys.filter((k) => k !== event.data.key),
|
|
58
|
+
results: { ...state.results, [event.data.key]: event.data.result },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case 'AwaitCompleted':
|
|
62
|
+
return { ...state, status: 'completed' };
|
|
63
|
+
default:
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import type { PhasedInput, PhasedOutput, PhasedState } from './phased-workflow.js';
|
|
2
|
+
import { createPhasedWorkflow, decide, evolve, initialState } from './phased-workflow.js';
|
|
3
|
+
|
|
4
|
+
describe('phased workflow', () => {
|
|
5
|
+
describe('evolve', () => {
|
|
6
|
+
it('transitions from idle to running on StartPhased', () => {
|
|
7
|
+
const state = initialState();
|
|
8
|
+
const event: PhasedInput = {
|
|
9
|
+
type: 'StartPhased',
|
|
10
|
+
data: {
|
|
11
|
+
correlationId: 'corr-1',
|
|
12
|
+
items: [
|
|
13
|
+
{ key: 'a', phase: 'p0' },
|
|
14
|
+
{ key: 'b', phase: 'p1' },
|
|
15
|
+
],
|
|
16
|
+
phases: ['p0', 'p1'],
|
|
17
|
+
stopOnFailure: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const result = evolve(state, event);
|
|
22
|
+
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
status: 'running',
|
|
25
|
+
items: [
|
|
26
|
+
{ key: 'a', phase: 'p0', status: 'pending' },
|
|
27
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
28
|
+
],
|
|
29
|
+
phases: ['p0', 'p1'],
|
|
30
|
+
currentPhaseIndex: 0,
|
|
31
|
+
stopOnFailure: true,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('marks item as dispatched on DispatchItem output', () => {
|
|
36
|
+
const state: PhasedState = {
|
|
37
|
+
status: 'running',
|
|
38
|
+
items: [
|
|
39
|
+
{ key: 'a', phase: 'p0', status: 'pending' },
|
|
40
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
41
|
+
],
|
|
42
|
+
phases: ['p0', 'p1'],
|
|
43
|
+
currentPhaseIndex: 0,
|
|
44
|
+
stopOnFailure: false,
|
|
45
|
+
};
|
|
46
|
+
const event: PhasedOutput = {
|
|
47
|
+
type: 'DispatchItem',
|
|
48
|
+
kind: 'Command',
|
|
49
|
+
data: { itemKey: 'a', phase: 'p0' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const result = evolve(state, event);
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
status: 'running',
|
|
56
|
+
items: [
|
|
57
|
+
{ key: 'a', phase: 'p0', status: 'dispatched' },
|
|
58
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
59
|
+
],
|
|
60
|
+
phases: ['p0', 'p1'],
|
|
61
|
+
currentPhaseIndex: 0,
|
|
62
|
+
stopOnFailure: false,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('marks item as completed on ItemCompleted', () => {
|
|
67
|
+
const state: PhasedState = {
|
|
68
|
+
status: 'running',
|
|
69
|
+
items: [
|
|
70
|
+
{ key: 'a', phase: 'p0', status: 'dispatched' },
|
|
71
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
72
|
+
],
|
|
73
|
+
phases: ['p0', 'p1'],
|
|
74
|
+
currentPhaseIndex: 0,
|
|
75
|
+
stopOnFailure: false,
|
|
76
|
+
};
|
|
77
|
+
const event: PhasedInput = {
|
|
78
|
+
type: 'ItemCompleted',
|
|
79
|
+
data: { itemKey: 'a', result: { output: 'done' } },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = evolve(state, event);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({
|
|
85
|
+
status: 'running',
|
|
86
|
+
items: [
|
|
87
|
+
{ key: 'a', phase: 'p0', status: 'completed' },
|
|
88
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
89
|
+
],
|
|
90
|
+
phases: ['p0', 'p1'],
|
|
91
|
+
currentPhaseIndex: 0,
|
|
92
|
+
stopOnFailure: false,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('marks item as failed on ItemFailed', () => {
|
|
97
|
+
const state: PhasedState = {
|
|
98
|
+
status: 'running',
|
|
99
|
+
items: [
|
|
100
|
+
{ key: 'a', phase: 'p0', status: 'dispatched' },
|
|
101
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
102
|
+
],
|
|
103
|
+
phases: ['p0', 'p1'],
|
|
104
|
+
currentPhaseIndex: 0,
|
|
105
|
+
stopOnFailure: true,
|
|
106
|
+
};
|
|
107
|
+
const event: PhasedInput = {
|
|
108
|
+
type: 'ItemFailed',
|
|
109
|
+
data: { itemKey: 'a', error: { message: 'boom' } },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const result = evolve(state, event);
|
|
113
|
+
|
|
114
|
+
expect(result).toEqual({
|
|
115
|
+
status: 'running',
|
|
116
|
+
items: [
|
|
117
|
+
{ key: 'a', phase: 'p0', status: 'failed' },
|
|
118
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
119
|
+
],
|
|
120
|
+
phases: ['p0', 'p1'],
|
|
121
|
+
currentPhaseIndex: 0,
|
|
122
|
+
stopOnFailure: true,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('sets status to completed on PhasedCompleted output', () => {
|
|
127
|
+
const state: PhasedState = {
|
|
128
|
+
status: 'running',
|
|
129
|
+
items: [{ key: 'a', phase: 'p0', status: 'completed' }],
|
|
130
|
+
phases: ['p0'],
|
|
131
|
+
currentPhaseIndex: 0,
|
|
132
|
+
stopOnFailure: false,
|
|
133
|
+
};
|
|
134
|
+
const event: PhasedOutput = {
|
|
135
|
+
type: 'PhasedCompleted',
|
|
136
|
+
data: { completedItems: ['a'] },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = evolve(state, event);
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
status: 'completed',
|
|
143
|
+
items: [{ key: 'a', phase: 'p0', status: 'completed' }],
|
|
144
|
+
phases: ['p0'],
|
|
145
|
+
currentPhaseIndex: 0,
|
|
146
|
+
stopOnFailure: false,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('sets status to failed on PhasedFailed output', () => {
|
|
151
|
+
const state: PhasedState = {
|
|
152
|
+
status: 'running',
|
|
153
|
+
items: [
|
|
154
|
+
{ key: 'a', phase: 'p0', status: 'failed' },
|
|
155
|
+
{ key: 'b', phase: 'p0', status: 'completed' },
|
|
156
|
+
],
|
|
157
|
+
phases: ['p0'],
|
|
158
|
+
currentPhaseIndex: 0,
|
|
159
|
+
stopOnFailure: true,
|
|
160
|
+
};
|
|
161
|
+
const event: PhasedOutput = {
|
|
162
|
+
type: 'PhasedFailed',
|
|
163
|
+
data: { failedItems: ['a'], completedItems: ['b'] },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = evolve(state, event);
|
|
167
|
+
|
|
168
|
+
expect(result).toEqual({
|
|
169
|
+
status: 'failed',
|
|
170
|
+
items: [
|
|
171
|
+
{ key: 'a', phase: 'p0', status: 'failed' },
|
|
172
|
+
{ key: 'b', phase: 'p0', status: 'completed' },
|
|
173
|
+
],
|
|
174
|
+
phases: ['p0'],
|
|
175
|
+
currentPhaseIndex: 0,
|
|
176
|
+
stopOnFailure: true,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('advances currentPhaseIndex when dispatching item from next phase', () => {
|
|
181
|
+
const state: PhasedState = {
|
|
182
|
+
status: 'running',
|
|
183
|
+
items: [
|
|
184
|
+
{ key: 'a', phase: 'p0', status: 'completed' },
|
|
185
|
+
{ key: 'b', phase: 'p1', status: 'pending' },
|
|
186
|
+
],
|
|
187
|
+
phases: ['p0', 'p1'],
|
|
188
|
+
currentPhaseIndex: 0,
|
|
189
|
+
stopOnFailure: false,
|
|
190
|
+
};
|
|
191
|
+
const event: PhasedOutput = {
|
|
192
|
+
type: 'DispatchItem',
|
|
193
|
+
kind: 'Command',
|
|
194
|
+
data: { itemKey: 'b', phase: 'p1' },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const result = evolve(state, event);
|
|
198
|
+
|
|
199
|
+
expect(result).toEqual({
|
|
200
|
+
status: 'running',
|
|
201
|
+
items: [
|
|
202
|
+
{ key: 'a', phase: 'p0', status: 'completed' },
|
|
203
|
+
{ key: 'b', phase: 'p1', status: 'dispatched' },
|
|
204
|
+
],
|
|
205
|
+
phases: ['p0', 'p1'],
|
|
206
|
+
currentPhaseIndex: 1,
|
|
207
|
+
stopOnFailure: false,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not change state for unknown event type', () => {
|
|
212
|
+
const state: PhasedState = {
|
|
213
|
+
status: 'running',
|
|
214
|
+
items: [{ key: 'a', phase: 'p0', status: 'pending' }],
|
|
215
|
+
phases: ['p0'],
|
|
216
|
+
currentPhaseIndex: 0,
|
|
217
|
+
stopOnFailure: false,
|
|
218
|
+
};
|
|
219
|
+
const event = { type: 'UnknownEvent', data: {} } as unknown as PhasedInput;
|
|
220
|
+
|
|
221
|
+
const result = evolve(state, event);
|
|
222
|
+
|
|
223
|
+
expect(result).toEqual(state);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('decide', () => {
|
|
228
|
+
it('dispatches next phase items when current phase completes', () => {
|
|
229
|
+
const state: PhasedState = {
|
|
230
|
+
status: 'running',
|
|
231
|
+
items: [
|
|
232
|
+
{ key: 'a', phase: 'validate', status: 'completed' },
|
|
233
|
+
{ key: 'b', phase: 'validate', status: 'completed' },
|
|
234
|
+
{ key: 'c', phase: 'import', status: 'pending' },
|
|
235
|
+
],
|
|
236
|
+
phases: ['validate', 'import'],
|
|
237
|
+
currentPhaseIndex: 0,
|
|
238
|
+
stopOnFailure: true,
|
|
239
|
+
};
|
|
240
|
+
const input: PhasedInput = {
|
|
241
|
+
type: 'ItemCompleted',
|
|
242
|
+
data: { itemKey: 'b', result: { ok: true } },
|
|
243
|
+
};
|
|
244
|
+
const result = decide(input, state);
|
|
245
|
+
expect(result).toEqual([{ type: 'DispatchItem', kind: 'Command', data: { itemKey: 'c', phase: 'import' } }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns empty when current phase still has pending items', () => {
|
|
249
|
+
const state: PhasedState = {
|
|
250
|
+
status: 'running',
|
|
251
|
+
items: [
|
|
252
|
+
{ key: 'a', phase: 'validate', status: 'completed' },
|
|
253
|
+
{ key: 'b', phase: 'validate', status: 'dispatched' },
|
|
254
|
+
],
|
|
255
|
+
phases: ['validate', 'import'],
|
|
256
|
+
currentPhaseIndex: 0,
|
|
257
|
+
stopOnFailure: true,
|
|
258
|
+
};
|
|
259
|
+
const input: PhasedInput = {
|
|
260
|
+
type: 'ItemCompleted',
|
|
261
|
+
data: { itemKey: 'a', result: { ok: true } },
|
|
262
|
+
};
|
|
263
|
+
const result = decide(input, state);
|
|
264
|
+
expect(result).toEqual([]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('returns PhasedFailed on item failure when stopOnFailure is true', () => {
|
|
268
|
+
const state: PhasedState = {
|
|
269
|
+
status: 'running',
|
|
270
|
+
items: [
|
|
271
|
+
{ key: 'a', phase: 'validate', status: 'failed' },
|
|
272
|
+
{ key: 'b', phase: 'validate', status: 'dispatched' },
|
|
273
|
+
],
|
|
274
|
+
phases: ['validate', 'import'],
|
|
275
|
+
currentPhaseIndex: 0,
|
|
276
|
+
stopOnFailure: true,
|
|
277
|
+
};
|
|
278
|
+
const input: PhasedInput = {
|
|
279
|
+
type: 'ItemFailed',
|
|
280
|
+
data: { itemKey: 'a', error: { reason: 'invalid' } },
|
|
281
|
+
};
|
|
282
|
+
const result = decide(input, state);
|
|
283
|
+
expect(result).toEqual({
|
|
284
|
+
type: 'PhasedFailed',
|
|
285
|
+
data: { failedItems: ['a'], completedItems: [] },
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('returns empty on item failure when stopOnFailure is false', () => {
|
|
290
|
+
const state: PhasedState = {
|
|
291
|
+
status: 'running',
|
|
292
|
+
items: [
|
|
293
|
+
{ key: 'a', phase: 'validate', status: 'failed' },
|
|
294
|
+
{ key: 'b', phase: 'validate', status: 'dispatched' },
|
|
295
|
+
],
|
|
296
|
+
phases: ['validate', 'import'],
|
|
297
|
+
currentPhaseIndex: 0,
|
|
298
|
+
stopOnFailure: false,
|
|
299
|
+
};
|
|
300
|
+
const input: PhasedInput = {
|
|
301
|
+
type: 'ItemFailed',
|
|
302
|
+
data: { itemKey: 'a', error: { reason: 'invalid' } },
|
|
303
|
+
};
|
|
304
|
+
const result = decide(input, state);
|
|
305
|
+
expect(result).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('returns PhasedCompleted when all items across all phases complete', () => {
|
|
309
|
+
const state: PhasedState = {
|
|
310
|
+
status: 'running',
|
|
311
|
+
items: [
|
|
312
|
+
{ key: 'a', phase: 'validate', status: 'completed' },
|
|
313
|
+
{ key: 'b', phase: 'import', status: 'completed' },
|
|
314
|
+
],
|
|
315
|
+
phases: ['validate', 'import'],
|
|
316
|
+
currentPhaseIndex: 1,
|
|
317
|
+
stopOnFailure: true,
|
|
318
|
+
};
|
|
319
|
+
const input: PhasedInput = {
|
|
320
|
+
type: 'ItemCompleted',
|
|
321
|
+
data: { itemKey: 'b', result: { ok: true } },
|
|
322
|
+
};
|
|
323
|
+
const result = decide(input, state);
|
|
324
|
+
expect(result).toEqual({
|
|
325
|
+
type: 'PhasedCompleted',
|
|
326
|
+
data: { completedItems: ['a', 'b'] },
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('dispatches phase 0 items on StartPhased', () => {
|
|
331
|
+
const state: PhasedState = {
|
|
332
|
+
status: 'running',
|
|
333
|
+
items: [
|
|
334
|
+
{ key: 'a', phase: 'validate', status: 'pending' },
|
|
335
|
+
{ key: 'b', phase: 'validate', status: 'pending' },
|
|
336
|
+
{ key: 'c', phase: 'import', status: 'pending' },
|
|
337
|
+
],
|
|
338
|
+
phases: ['validate', 'import'],
|
|
339
|
+
currentPhaseIndex: 0,
|
|
340
|
+
stopOnFailure: true,
|
|
341
|
+
};
|
|
342
|
+
const input: PhasedInput = {
|
|
343
|
+
type: 'StartPhased',
|
|
344
|
+
data: {
|
|
345
|
+
correlationId: 'c1',
|
|
346
|
+
items: [
|
|
347
|
+
{ key: 'a', phase: 'validate' },
|
|
348
|
+
{ key: 'b', phase: 'validate' },
|
|
349
|
+
{ key: 'c', phase: 'import' },
|
|
350
|
+
],
|
|
351
|
+
phases: ['validate', 'import'],
|
|
352
|
+
stopOnFailure: true,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
const result = decide(input, state);
|
|
356
|
+
expect(result).toEqual([
|
|
357
|
+
{ type: 'DispatchItem', kind: 'Command', data: { itemKey: 'a', phase: 'validate' } },
|
|
358
|
+
{ type: 'DispatchItem', kind: 'Command', data: { itemKey: 'b', phase: 'validate' } },
|
|
359
|
+
]);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('initialState', () => {
|
|
364
|
+
it('returns idle state with empty collections', () => {
|
|
365
|
+
expect(initialState()).toEqual({
|
|
366
|
+
status: 'idle',
|
|
367
|
+
items: [],
|
|
368
|
+
phases: [],
|
|
369
|
+
currentPhaseIndex: 0,
|
|
370
|
+
stopOnFailure: false,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('createPhasedWorkflow', () => {
|
|
376
|
+
it('factory produces workflow with decide, evolve, initialState', () => {
|
|
377
|
+
const workflow = createPhasedWorkflow();
|
|
378
|
+
expect(workflow.decide).toBe(decide);
|
|
379
|
+
expect(workflow.evolve).toBe(evolve);
|
|
380
|
+
expect(workflow.initialState).toBe(initialState);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|