@auto-engineer/pipeline 1.68.0 → 1.69.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 +29 -0
- package/dist/src/engine/workflow-processor.d.ts +3 -0
- package/dist/src/engine/workflow-processor.d.ts.map +1 -1
- package/dist/src/engine/workflow-processor.js +50 -7
- package/dist/src/engine/workflow-processor.js.map +1 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +0 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/server/phased-bridge.d.ts +13 -0
- package/dist/src/server/phased-bridge.d.ts.map +1 -0
- package/dist/src/server/phased-bridge.js +103 -0
- package/dist/src/server/phased-bridge.js.map +1 -0
- package/dist/src/server/pipeline-server.d.ts +2 -2
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.js +18 -38
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/src/server/v2-runtime-bridge.d.ts +21 -0
- package/dist/src/server/v2-runtime-bridge.d.ts.map +1 -0
- package/dist/src/server/v2-runtime-bridge.js +182 -0
- package/dist/src/server/v2-runtime-bridge.js.map +1 -0
- package/dist/src/store/pipeline-event-store.d.ts.map +1 -1
- package/dist/src/store/pipeline-event-store.js +0 -30
- package/dist/src/store/pipeline-event-store.js.map +1 -1
- package/dist/src/store/pipeline-read-model.d.ts +0 -15
- package/dist/src/store/pipeline-read-model.d.ts.map +1 -1
- package/dist/src/store/pipeline-read-model.js +0 -49
- package/dist/src/store/pipeline-read-model.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +10 -12
- package/package.json +3 -3
- package/src/engine/workflow-processor.specs.ts +101 -0
- package/src/engine/workflow-processor.ts +54 -8
- package/src/index.ts +0 -2
- package/src/server/phased-bridge.specs.ts +272 -0
- package/src/server/phased-bridge.ts +130 -0
- package/src/server/pipeline-server.ts +20 -41
- package/src/server/v2-runtime-bridge.specs.ts +347 -0
- package/src/server/v2-runtime-bridge.ts +246 -0
- package/src/store/pipeline-event-store.specs.ts +0 -137
- package/src/store/pipeline-event-store.ts +0 -35
- package/src/store/pipeline-read-model.specs.ts +0 -567
- package/src/store/pipeline-read-model.ts +0 -71
- package/dist/src/projections/phased-execution-projection.d.ts +0 -77
- package/dist/src/projections/phased-execution-projection.d.ts.map +0 -1
- package/dist/src/projections/phased-execution-projection.js +0 -54
- package/dist/src/projections/phased-execution-projection.js.map +0 -1
- package/dist/src/projections/settled-instance-projection.d.ts +0 -67
- package/dist/src/projections/settled-instance-projection.d.ts.map +0 -1
- package/dist/src/projections/settled-instance-projection.js +0 -66
- package/dist/src/projections/settled-instance-projection.js.map +0 -1
- package/dist/src/runtime/phased-executor.d.ts +0 -34
- package/dist/src/runtime/phased-executor.d.ts.map +0 -1
- package/dist/src/runtime/phased-executor.js +0 -172
- package/dist/src/runtime/phased-executor.js.map +0 -1
- package/dist/src/runtime/settled-tracker.d.ts +0 -44
- package/dist/src/runtime/settled-tracker.d.ts.map +0 -1
- package/dist/src/runtime/settled-tracker.js +0 -170
- package/dist/src/runtime/settled-tracker.js.map +0 -1
- package/src/projections/phased-execution-projection.specs.ts +0 -202
- package/src/projections/phased-execution-projection.ts +0 -146
- package/src/projections/settled-instance-projection.specs.ts +0 -296
- package/src/projections/settled-instance-projection.ts +0 -160
- package/src/runtime/phased-executor.specs.ts +0 -680
- package/src/runtime/phased-executor.ts +0 -230
- package/src/runtime/settled-tracker.specs.ts +0 -1044
- package/src/runtime/settled-tracker.ts +0 -235
package/ketchup-plan.md
CHANGED
|
@@ -2,23 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## TODO
|
|
4
4
|
|
|
5
|
-
### Session-Based Status Tracking (Bursts S-1 to S-4) ✅
|
|
6
|
-
|
|
7
|
-
- [x] Burst S-1: E2E test — sub-commands overwrite session status (b8246300)
|
|
8
|
-
- [x] Burst S-2: E2E test — sub-command items counted under unified view (b8246300)
|
|
9
|
-
- [x] Burst S-3: Add sessionId and RestartPipeline (b8246300)
|
|
10
|
-
- [x] Burst S-4: Track status under sessionId (b8246300)
|
|
11
5
|
|
|
12
6
|
### Graph Rendering Fix (Burst 106)
|
|
13
7
|
|
|
14
8
|
- [ ] Burst 106: Show source commands whose events are listened to by the pipeline [depends: none]
|
|
15
9
|
|
|
16
|
-
### Parallel Scatter-Gather in Event Router (Bursts P-1 to P-3)
|
|
17
|
-
|
|
18
|
-
- [x] Burst P-1: Test that emit mapping commands run in parallel [depends: none]
|
|
19
|
-
- [x] Burst P-2: Fix event router to use Promise.all [depends: P-1]
|
|
20
|
-
- [x] Burst P-3: Settled scatter-gather proves parallel [depends: P-2]
|
|
21
|
-
|
|
22
10
|
### Phase 11: 100% Test Coverage (Bursts 93-102)
|
|
23
11
|
|
|
24
12
|
**Goal**: Achieve 100% test coverage by testing uncovered code or removing dead code.
|
|
@@ -931,6 +919,16 @@ it("should extract graph from emit handler", () => {
|
|
|
931
919
|
|
|
932
920
|
## DONE
|
|
933
921
|
|
|
922
|
+
### V2 Engine Internal Swap ✅
|
|
923
|
+
|
|
924
|
+
- [x] Burst V2-1: Add processKeyed/getState/resetInstance to WorkflowProcessor [depends: none] (d7b0fbc6)
|
|
925
|
+
- [x] Burst V2-2: V2RuntimeBridge — settled path [depends: V2-1] (867c844a)
|
|
926
|
+
- [x] Burst V2-3: V2RuntimeBridge — phased path [depends: V2-1] (985add45)
|
|
927
|
+
- [x] Burst V2-4: Wire bridge into PipelineServer [depends: V2-2, V2-3] (df6e6fcf)
|
|
928
|
+
- [x] Burst V2-5+V2-6: Remove v1 runtime classes and update exports [depends: V2-4] (98b1025c)
|
|
929
|
+
|
|
930
|
+
---
|
|
931
|
+
|
|
934
932
|
### Phase 12: Consistent Non-Blocking Dispatch (Bursts 103-105) ✅
|
|
935
933
|
|
|
936
934
|
- [x] Burst 103: Add `await` to `startPhased` call in pipeline-runtime.ts (e9b391f4)
|
package/package.json
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"get-port": "^7.1.0",
|
|
14
14
|
"jose": "^5.9.6",
|
|
15
15
|
"nanoid": "^5.0.0",
|
|
16
|
-
"@auto-engineer/file-store": "1.
|
|
17
|
-
"@auto-engineer/message-bus": "1.
|
|
16
|
+
"@auto-engineer/file-store": "1.69.0",
|
|
17
|
+
"@auto-engineer/message-bus": "1.69.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/cors": "^2.8.17",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
|
25
25
|
},
|
|
26
|
-
"version": "1.
|
|
26
|
+
"version": "1.69.0",
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
|
|
29
29
|
"test": "vitest run --reporter=dot",
|
|
@@ -34,4 +34,105 @@ describe('WorkflowProcessor', () => {
|
|
|
34
34
|
const result = processor.process({ type: 'Unknown', data: {} });
|
|
35
35
|
expect(result).toEqual([]);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
it('processes keyed events independently per instanceKey', () => {
|
|
39
|
+
const processor = createWorkflowProcessor();
|
|
40
|
+
processor.register({
|
|
41
|
+
id: 'test-settled',
|
|
42
|
+
workflow: createSettledWorkflow({ commandTypes: ['A', 'B'] }),
|
|
43
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
processor.processKeyed({ type: 'StartSettled', data: { commandTypes: ['A', 'B'] } }, 'key-1');
|
|
47
|
+
processor.processKeyed({ type: 'StartSettled', data: { commandTypes: ['A', 'B'] } }, 'key-2');
|
|
48
|
+
|
|
49
|
+
processor.processKeyed(
|
|
50
|
+
{ type: 'CommandCompleted', data: { commandType: 'A', result: 'success', event: {} } },
|
|
51
|
+
'key-1',
|
|
52
|
+
);
|
|
53
|
+
processor.processKeyed(
|
|
54
|
+
{ type: 'CommandCompleted', data: { commandType: 'A', result: 'success', event: {} } },
|
|
55
|
+
'key-2',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const result1 = processor.processKeyed(
|
|
59
|
+
{ type: 'CommandCompleted', data: { commandType: 'B', result: 'success', event: {} } },
|
|
60
|
+
'key-1',
|
|
61
|
+
);
|
|
62
|
+
expect(result1).toEqual([expect.objectContaining({ type: 'AllSettled' })]);
|
|
63
|
+
|
|
64
|
+
const result2 = processor.processKeyed(
|
|
65
|
+
{ type: 'CommandCompleted', data: { commandType: 'B', result: 'failure', event: {} } },
|
|
66
|
+
'key-2',
|
|
67
|
+
);
|
|
68
|
+
expect(result2).toEqual([expect.objectContaining({ type: 'RetryCommands' })]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('getState returns current state for given key', () => {
|
|
72
|
+
const processor = createWorkflowProcessor();
|
|
73
|
+
processor.register({
|
|
74
|
+
id: 'test-settled',
|
|
75
|
+
workflow: createSettledWorkflow({ commandTypes: ['A'] }),
|
|
76
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
processor.processKeyed({ type: 'StartSettled', data: { commandTypes: ['A'] } }, 'key-1');
|
|
80
|
+
processor.processKeyed(
|
|
81
|
+
{ type: 'CommandCompleted', data: { commandType: 'A', result: 'success', event: { v: 1 } } },
|
|
82
|
+
'key-1',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const state = processor.getState('test-settled', 'key-1');
|
|
86
|
+
expect(state).toEqual({
|
|
87
|
+
status: 'done',
|
|
88
|
+
commandTypes: ['A'],
|
|
89
|
+
completions: { A: { result: 'success', event: { v: 1 } } },
|
|
90
|
+
retryCount: 0,
|
|
91
|
+
maxRetries: 3,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('getState returns initial state for unknown key', () => {
|
|
96
|
+
const processor = createWorkflowProcessor();
|
|
97
|
+
processor.register({
|
|
98
|
+
id: 'test-settled',
|
|
99
|
+
workflow: createSettledWorkflow({ commandTypes: ['A'] }),
|
|
100
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const state = processor.getState('test-settled', 'unknown-key');
|
|
104
|
+
expect(state).toEqual({
|
|
105
|
+
status: 'idle',
|
|
106
|
+
commandTypes: [],
|
|
107
|
+
completions: {},
|
|
108
|
+
retryCount: 0,
|
|
109
|
+
maxRetries: 3,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('resetInstance clears keyed history', () => {
|
|
114
|
+
const processor = createWorkflowProcessor();
|
|
115
|
+
processor.register({
|
|
116
|
+
id: 'test-settled',
|
|
117
|
+
workflow: createSettledWorkflow({ commandTypes: ['A'] }),
|
|
118
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
processor.processKeyed({ type: 'StartSettled', data: { commandTypes: ['A'] } }, 'key-1');
|
|
122
|
+
processor.processKeyed(
|
|
123
|
+
{ type: 'CommandCompleted', data: { commandType: 'A', result: 'success', event: {} } },
|
|
124
|
+
'key-1',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
processor.resetInstance('test-settled', 'key-1');
|
|
128
|
+
|
|
129
|
+
const state = processor.getState('test-settled', 'key-1');
|
|
130
|
+
expect(state).toEqual({
|
|
131
|
+
status: 'idle',
|
|
132
|
+
commandTypes: [],
|
|
133
|
+
completions: {},
|
|
134
|
+
retryCount: 0,
|
|
135
|
+
maxRetries: 3,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
37
138
|
});
|
|
@@ -15,6 +15,7 @@ export type WorkflowRegistration = {
|
|
|
15
15
|
export function createWorkflowProcessor() {
|
|
16
16
|
const registrations = new Map<string, WorkflowRegistration>();
|
|
17
17
|
const eventsByWorkflow = new Map<string, Event[]>();
|
|
18
|
+
const keyedEvents = new Map<string, Map<string, Event[]>>();
|
|
18
19
|
|
|
19
20
|
function rebuildState(reg: WorkflowRegistration, events: Event[]): unknown {
|
|
20
21
|
let state = reg.workflow.initialState();
|
|
@@ -24,6 +25,33 @@ export function createWorkflowProcessor() {
|
|
|
24
25
|
return state;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function getKeyedHistory(workflowId: string, instanceKey: string): Event[] {
|
|
29
|
+
let instances = keyedEvents.get(workflowId);
|
|
30
|
+
if (!instances) {
|
|
31
|
+
instances = new Map();
|
|
32
|
+
keyedEvents.set(workflowId, instances);
|
|
33
|
+
}
|
|
34
|
+
let history = instances.get(instanceKey);
|
|
35
|
+
if (!history) {
|
|
36
|
+
history = [];
|
|
37
|
+
instances.set(instanceKey, history);
|
|
38
|
+
}
|
|
39
|
+
return history;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function processWithHistory(reg: WorkflowRegistration, event: Event, history: Event[]): Event[] {
|
|
43
|
+
const outputs: Event[] = [];
|
|
44
|
+
history.push(event);
|
|
45
|
+
const state = rebuildState(reg, history);
|
|
46
|
+
const result = reg.workflow.decide(event, state);
|
|
47
|
+
const resultEvents = Array.isArray(result) ? result : [result];
|
|
48
|
+
for (const outputEvent of resultEvents) {
|
|
49
|
+
history.push(outputEvent);
|
|
50
|
+
outputs.push(outputEvent);
|
|
51
|
+
}
|
|
52
|
+
return outputs;
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
return {
|
|
28
56
|
register(registration: WorkflowRegistration): void {
|
|
29
57
|
registrations.set(registration.id, registration);
|
|
@@ -37,21 +65,39 @@ export function createWorkflowProcessor() {
|
|
|
37
65
|
if (!reg.inputEvents.includes(event.type)) {
|
|
38
66
|
continue;
|
|
39
67
|
}
|
|
40
|
-
|
|
41
68
|
const history = eventsByWorkflow.get(id)!;
|
|
42
|
-
|
|
69
|
+
outputs.push(...processWithHistory(reg, event, history));
|
|
70
|
+
}
|
|
43
71
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const resultEvents = Array.isArray(result) ? result : [result];
|
|
72
|
+
return outputs;
|
|
73
|
+
},
|
|
47
74
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
processKeyed(event: Event, instanceKey: string): Event[] {
|
|
76
|
+
const outputs: Event[] = [];
|
|
77
|
+
|
|
78
|
+
for (const [id, reg] of registrations) {
|
|
79
|
+
if (!reg.inputEvents.includes(event.type)) {
|
|
80
|
+
continue;
|
|
51
81
|
}
|
|
82
|
+
const history = getKeyedHistory(id, instanceKey);
|
|
83
|
+
outputs.push(...processWithHistory(reg, event, history));
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
return outputs;
|
|
55
87
|
},
|
|
88
|
+
|
|
89
|
+
getState(workflowId: string, instanceKey: string): unknown {
|
|
90
|
+
const reg = registrations.get(workflowId);
|
|
91
|
+
if (!reg) return undefined;
|
|
92
|
+
const history = getKeyedHistory(workflowId, instanceKey);
|
|
93
|
+
return rebuildState(reg, history);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
resetInstance(workflowId: string, instanceKey: string): void {
|
|
97
|
+
const instances = keyedEvents.get(workflowId);
|
|
98
|
+
if (instances) {
|
|
99
|
+
instances.delete(instanceKey);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
56
102
|
};
|
|
57
103
|
}
|
package/src/index.ts
CHANGED
|
@@ -42,9 +42,7 @@ export type { AwaitEvent, AwaitTrackerDocument } from './projections/await-track
|
|
|
42
42
|
export { AwaitTracker } from './runtime/await-tracker';
|
|
43
43
|
export type { PipelineContext, RuntimeConfig } from './runtime/context';
|
|
44
44
|
export { EventCommandMapper } from './runtime/event-command-map';
|
|
45
|
-
export { PhasedExecutor } from './runtime/phased-executor';
|
|
46
45
|
export { PipelineRuntime } from './runtime/pipeline-runtime';
|
|
47
|
-
export { SettledTracker } from './runtime/settled-tracker';
|
|
48
46
|
export type { CommandHandlerWithMetadata, PipelineServerConfig } from './server/pipeline-server';
|
|
49
47
|
export { PipelineServer } from './server/pipeline-server';
|
|
50
48
|
export { SSEManager } from './server/sse-manager';
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { Event } from '@auto-engineer/message-bus';
|
|
2
|
+
import type { ForEachPhasedDescriptor } from '../core/descriptors.js';
|
|
3
|
+
import type { CommandDispatch } from '../core/types.js';
|
|
4
|
+
import { createPhasedBridge } from './phased-bridge.js';
|
|
5
|
+
|
|
6
|
+
interface TestItem {
|
|
7
|
+
id: string;
|
|
8
|
+
phase: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function prop(obj: unknown, key: string): string {
|
|
12
|
+
return String(Object(obj)[key]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeEvent(type: string, correlationId: string, data: Record<string, unknown> = {}): Event {
|
|
16
|
+
return { type, data, correlationId };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeDescriptor(overrides: Partial<ForEachPhasedDescriptor> = {}): ForEachPhasedDescriptor {
|
|
20
|
+
return {
|
|
21
|
+
type: 'foreach-phased',
|
|
22
|
+
eventType: 'BuildTriggered',
|
|
23
|
+
phases: ['validate', 'compile'],
|
|
24
|
+
stopOnFailure: false,
|
|
25
|
+
itemsSelector: (event: Event): TestItem[] => {
|
|
26
|
+
const items = event.data.items;
|
|
27
|
+
if (!Array.isArray(items)) return [];
|
|
28
|
+
return items;
|
|
29
|
+
},
|
|
30
|
+
classifier: (item: unknown) => prop(item, 'phase'),
|
|
31
|
+
emitFactory: (item: unknown, phase: string, _event: Event): CommandDispatch => ({
|
|
32
|
+
commandType: `Run${phase.charAt(0).toUpperCase()}${phase.slice(1)}`,
|
|
33
|
+
data: { key: prop(item, 'id'), phase },
|
|
34
|
+
}),
|
|
35
|
+
completion: {
|
|
36
|
+
successEvent: { name: 'BuildCompleted' },
|
|
37
|
+
failureEvent: { name: 'BuildFailed' },
|
|
38
|
+
itemKey: (event: Event) => String(event.data.id),
|
|
39
|
+
},
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('PhasedBridge', () => {
|
|
45
|
+
describe('startPhased dispatches phase 0 items', () => {
|
|
46
|
+
it('calls onDispatch for each item in the first phase', () => {
|
|
47
|
+
const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
|
|
48
|
+
const bridge = createPhasedBridge({
|
|
49
|
+
onDispatch: (commandType, data, correlationId) => {
|
|
50
|
+
dispatched.push({ commandType, data, correlationId });
|
|
51
|
+
},
|
|
52
|
+
onPhasedComplete: () => {},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const descriptor = makeDescriptor();
|
|
56
|
+
bridge.registerPhased(descriptor);
|
|
57
|
+
|
|
58
|
+
const triggerEvent = makeEvent('BuildTriggered', 'corr-1', {
|
|
59
|
+
items: [
|
|
60
|
+
{ id: 'item-a', phase: 'validate' },
|
|
61
|
+
{ id: 'item-b', phase: 'compile' },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
bridge.startPhased(descriptor, triggerEvent, 'corr-1');
|
|
66
|
+
|
|
67
|
+
expect(dispatched).toEqual([
|
|
68
|
+
{ commandType: 'RunValidate', data: { key: 'item-a', phase: 'validate' }, correlationId: 'corr-1' },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('item completion advances to next phase', () => {
|
|
74
|
+
it('dispatches next phase items after current phase completes', () => {
|
|
75
|
+
const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
|
|
76
|
+
const bridge = createPhasedBridge({
|
|
77
|
+
onDispatch: (commandType, data, correlationId) => {
|
|
78
|
+
dispatched.push({ commandType, data, correlationId });
|
|
79
|
+
},
|
|
80
|
+
onPhasedComplete: () => {},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const descriptor = makeDescriptor();
|
|
84
|
+
bridge.registerPhased(descriptor);
|
|
85
|
+
|
|
86
|
+
const triggerEvent = makeEvent('BuildTriggered', 'corr-1', {
|
|
87
|
+
items: [
|
|
88
|
+
{ id: 'item-a', phase: 'validate' },
|
|
89
|
+
{ id: 'item-b', phase: 'compile' },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
bridge.startPhased(descriptor, triggerEvent, 'corr-1');
|
|
94
|
+
dispatched.length = 0;
|
|
95
|
+
|
|
96
|
+
bridge.onPhasedItemEvent(makeEvent('ItemDone', 'corr-1', { id: 'item-a' }), 'item-a');
|
|
97
|
+
|
|
98
|
+
expect(dispatched).toEqual([
|
|
99
|
+
{ commandType: 'RunCompile', data: { key: 'item-b', phase: 'compile' }, correlationId: 'corr-1' },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('all phases complete triggers success', () => {
|
|
105
|
+
it('calls onPhasedComplete with success event when all items finish', () => {
|
|
106
|
+
const completed: Array<{ event: Event; correlationId: string }> = [];
|
|
107
|
+
const bridge = createPhasedBridge({
|
|
108
|
+
onDispatch: () => {},
|
|
109
|
+
onPhasedComplete: (event, correlationId) => {
|
|
110
|
+
completed.push({ event, correlationId });
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const descriptor = makeDescriptor();
|
|
115
|
+
bridge.registerPhased(descriptor);
|
|
116
|
+
|
|
117
|
+
const triggerEvent = makeEvent('BuildTriggered', 'corr-1', {
|
|
118
|
+
items: [
|
|
119
|
+
{ id: 'item-a', phase: 'validate' },
|
|
120
|
+
{ id: 'item-b', phase: 'compile' },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
bridge.startPhased(descriptor, triggerEvent, 'corr-1');
|
|
125
|
+
bridge.onPhasedItemEvent(makeEvent('ItemDone', 'corr-1', { id: 'item-a' }), 'item-a');
|
|
126
|
+
bridge.onPhasedItemEvent(makeEvent('ItemDone', 'corr-1', { id: 'item-b' }), 'item-b');
|
|
127
|
+
|
|
128
|
+
expect(completed).toEqual([
|
|
129
|
+
{
|
|
130
|
+
event: {
|
|
131
|
+
type: 'BuildCompleted',
|
|
132
|
+
correlationId: 'corr-1',
|
|
133
|
+
data: { results: ['item-a', 'item-b'], itemCount: 2 },
|
|
134
|
+
},
|
|
135
|
+
correlationId: 'corr-1',
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('stopOnFailure triggers failure event', () => {
|
|
142
|
+
it('calls onPhasedComplete with failure event when item fails and stopOnFailure is true', () => {
|
|
143
|
+
const completed: Array<{ event: Event; correlationId: string }> = [];
|
|
144
|
+
const bridge = createPhasedBridge({
|
|
145
|
+
onDispatch: () => {},
|
|
146
|
+
onPhasedComplete: (event, correlationId) => {
|
|
147
|
+
completed.push({ event, correlationId });
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const descriptor = makeDescriptor({ stopOnFailure: true });
|
|
152
|
+
bridge.registerPhased(descriptor);
|
|
153
|
+
|
|
154
|
+
const triggerEvent = makeEvent('BuildTriggered', 'corr-1', {
|
|
155
|
+
items: [
|
|
156
|
+
{ id: 'item-a', phase: 'validate' },
|
|
157
|
+
{ id: 'item-b', phase: 'validate' },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
bridge.startPhased(descriptor, triggerEvent, 'corr-1');
|
|
162
|
+
bridge.onPhasedItemEvent(makeEvent('BuildFailed', 'corr-1', { id: 'item-a' }), 'item-a');
|
|
163
|
+
|
|
164
|
+
expect(completed).toEqual([
|
|
165
|
+
{
|
|
166
|
+
event: {
|
|
167
|
+
type: 'BuildFailed',
|
|
168
|
+
correlationId: 'corr-1',
|
|
169
|
+
data: { failures: ['item-a'], completedItems: [] },
|
|
170
|
+
},
|
|
171
|
+
correlationId: 'corr-1',
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('independent correlationIds', () => {
|
|
178
|
+
it('tracks two correlationIds independently', () => {
|
|
179
|
+
const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
|
|
180
|
+
const completed: Array<{ event: Event; correlationId: string }> = [];
|
|
181
|
+
const bridge = createPhasedBridge({
|
|
182
|
+
onDispatch: (commandType, data, correlationId) => {
|
|
183
|
+
dispatched.push({ commandType, data, correlationId });
|
|
184
|
+
},
|
|
185
|
+
onPhasedComplete: (event, correlationId) => {
|
|
186
|
+
completed.push({ event, correlationId });
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const descriptor = makeDescriptor({
|
|
191
|
+
phases: ['validate'],
|
|
192
|
+
});
|
|
193
|
+
bridge.registerPhased(descriptor);
|
|
194
|
+
|
|
195
|
+
const trigger1 = makeEvent('BuildTriggered', 'corr-1', {
|
|
196
|
+
items: [{ id: 'item-x', phase: 'validate' }],
|
|
197
|
+
});
|
|
198
|
+
const trigger2 = makeEvent('BuildTriggered', 'corr-2', {
|
|
199
|
+
items: [{ id: 'item-y', phase: 'validate' }],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
bridge.startPhased(descriptor, trigger1, 'corr-1');
|
|
203
|
+
bridge.startPhased(descriptor, trigger2, 'corr-2');
|
|
204
|
+
dispatched.length = 0;
|
|
205
|
+
|
|
206
|
+
bridge.onPhasedItemEvent(makeEvent('ItemDone', 'corr-1', { id: 'item-x' }), 'item-x');
|
|
207
|
+
|
|
208
|
+
expect(completed).toEqual([
|
|
209
|
+
{
|
|
210
|
+
event: {
|
|
211
|
+
type: 'BuildCompleted',
|
|
212
|
+
correlationId: 'corr-1',
|
|
213
|
+
data: { results: ['item-x'], itemCount: 1 },
|
|
214
|
+
},
|
|
215
|
+
correlationId: 'corr-1',
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
bridge.onPhasedItemEvent(makeEvent('ItemDone', 'corr-2', { id: 'item-y' }), 'item-y');
|
|
220
|
+
|
|
221
|
+
expect(completed).toEqual([
|
|
222
|
+
{
|
|
223
|
+
event: {
|
|
224
|
+
type: 'BuildCompleted',
|
|
225
|
+
correlationId: 'corr-1',
|
|
226
|
+
data: { results: ['item-x'], itemCount: 1 },
|
|
227
|
+
},
|
|
228
|
+
correlationId: 'corr-1',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
event: {
|
|
232
|
+
type: 'BuildCompleted',
|
|
233
|
+
correlationId: 'corr-2',
|
|
234
|
+
data: { results: ['item-y'], itemCount: 1 },
|
|
235
|
+
},
|
|
236
|
+
correlationId: 'corr-2',
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('emitFactory with function data', () => {
|
|
243
|
+
it('resolves data factory functions when dispatching', () => {
|
|
244
|
+
const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
|
|
245
|
+
const bridge = createPhasedBridge({
|
|
246
|
+
onDispatch: (commandType, data, correlationId) => {
|
|
247
|
+
dispatched.push({ commandType, data, correlationId });
|
|
248
|
+
},
|
|
249
|
+
onPhasedComplete: () => {},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const descriptor = makeDescriptor({
|
|
253
|
+
phases: ['validate'],
|
|
254
|
+
emitFactory: (item: unknown, phase: string, event: Event): CommandDispatch => ({
|
|
255
|
+
commandType: `Run${phase.charAt(0).toUpperCase()}${phase.slice(1)}`,
|
|
256
|
+
data: (_ev: Event) => ({ key: prop(item, 'id'), source: event.type }),
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
bridge.registerPhased(descriptor);
|
|
260
|
+
|
|
261
|
+
const triggerEvent = makeEvent('BuildTriggered', 'corr-1', {
|
|
262
|
+
items: [{ id: 'item-a', phase: 'validate' }],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
bridge.startPhased(descriptor, triggerEvent, 'corr-1');
|
|
266
|
+
|
|
267
|
+
expect(dispatched).toEqual([
|
|
268
|
+
{ commandType: 'RunValidate', data: { key: 'item-a', source: 'BuildTriggered' }, correlationId: 'corr-1' },
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Event } from '@auto-engineer/message-bus';
|
|
2
|
+
import type { ForEachPhasedDescriptor } from '../core/descriptors.js';
|
|
3
|
+
import type { CommandDispatch } from '../core/types.js';
|
|
4
|
+
import type { PhasedInput, PhasedOutput, PhasedState } from '../engine/workflows/phased-workflow.js';
|
|
5
|
+
import { decide, evolve, initialState } from '../engine/workflows/phased-workflow.js';
|
|
6
|
+
|
|
7
|
+
interface PhasedBridgeConfig {
|
|
8
|
+
onDispatch: (commandType: string, data: unknown, correlationId: string) => void;
|
|
9
|
+
onPhasedComplete: (event: Event, correlationId: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PhasedExecution {
|
|
13
|
+
correlationId: string;
|
|
14
|
+
handler: ForEachPhasedDescriptor;
|
|
15
|
+
triggerEvent: Event;
|
|
16
|
+
items: Array<{ key: string; phase: string; original: unknown }>;
|
|
17
|
+
state: PhasedState;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveData(dispatch: CommandDispatch, event: Event): Record<string, unknown> {
|
|
21
|
+
if (typeof dispatch.data === 'function') {
|
|
22
|
+
return dispatch.data(event);
|
|
23
|
+
}
|
|
24
|
+
return dispatch.data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processInput(execution: PhasedExecution, input: PhasedInput): PhasedOutput[] {
|
|
28
|
+
let state = evolve(execution.state, input);
|
|
29
|
+
const result = decide(input, state);
|
|
30
|
+
const outputs = Array.isArray(result) ? result : [result];
|
|
31
|
+
for (const output of outputs) {
|
|
32
|
+
state = evolve(state, output);
|
|
33
|
+
}
|
|
34
|
+
execution.state = state;
|
|
35
|
+
return outputs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPhasedBridge(config: PhasedBridgeConfig) {
|
|
39
|
+
const descriptors = new Map<string, ForEachPhasedDescriptor>();
|
|
40
|
+
const executions = new Map<string, PhasedExecution>();
|
|
41
|
+
const itemToExecution = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
function handleOutputs(outputs: PhasedOutput[], execution: PhasedExecution): void {
|
|
44
|
+
for (const output of outputs) {
|
|
45
|
+
if (output.type === 'DispatchItem') {
|
|
46
|
+
const itemKey = output.data.itemKey;
|
|
47
|
+
const phase = output.data.phase;
|
|
48
|
+
const itemRecord = execution.items.find((i) => i.key === itemKey);
|
|
49
|
+
if (itemRecord) {
|
|
50
|
+
const command = execution.handler.emitFactory(itemRecord.original, phase, execution.triggerEvent);
|
|
51
|
+
const data = resolveData(command, execution.triggerEvent);
|
|
52
|
+
config.onDispatch(command.commandType, data, execution.correlationId);
|
|
53
|
+
}
|
|
54
|
+
} else if (output.type === 'PhasedCompleted') {
|
|
55
|
+
const completionEvent: Event = {
|
|
56
|
+
type: execution.handler.completion.successEvent.name,
|
|
57
|
+
correlationId: execution.correlationId,
|
|
58
|
+
data: { results: output.data.completedItems, itemCount: execution.items.length },
|
|
59
|
+
};
|
|
60
|
+
config.onPhasedComplete(completionEvent, execution.correlationId);
|
|
61
|
+
} else if (output.type === 'PhasedFailed') {
|
|
62
|
+
const failureEvent: Event = {
|
|
63
|
+
type: execution.handler.completion.failureEvent.name,
|
|
64
|
+
correlationId: execution.correlationId,
|
|
65
|
+
data: { failures: output.data.failedItems, completedItems: output.data.completedItems },
|
|
66
|
+
};
|
|
67
|
+
config.onPhasedComplete(failureEvent, execution.correlationId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
registerPhased(descriptor: ForEachPhasedDescriptor): void {
|
|
74
|
+
const handlerId = `phased-handler-${descriptor.eventType}`;
|
|
75
|
+
descriptors.set(handlerId, descriptor);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
startPhased(handler: ForEachPhasedDescriptor, event: Event, correlationId: string): void {
|
|
79
|
+
const items = handler.itemsSelector(event);
|
|
80
|
+
const itemRecords: PhasedExecution['items'] = [];
|
|
81
|
+
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const data: Record<string, unknown> = Object(item);
|
|
84
|
+
const key = handler.completion.itemKey({ type: event.type, data });
|
|
85
|
+
const phase = handler.classifier(item);
|
|
86
|
+
itemRecords.push({ key, phase, original: item });
|
|
87
|
+
itemToExecution.set(key, correlationId + '|' + handler.eventType);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const execution: PhasedExecution = {
|
|
91
|
+
correlationId,
|
|
92
|
+
handler,
|
|
93
|
+
triggerEvent: event,
|
|
94
|
+
items: itemRecords,
|
|
95
|
+
state: initialState(),
|
|
96
|
+
};
|
|
97
|
+
executions.set(correlationId + '|' + handler.eventType, execution);
|
|
98
|
+
|
|
99
|
+
const startInput: PhasedInput = {
|
|
100
|
+
type: 'StartPhased',
|
|
101
|
+
data: {
|
|
102
|
+
correlationId,
|
|
103
|
+
items: itemRecords.map((i) => ({ key: i.key, phase: i.phase })),
|
|
104
|
+
phases: [...handler.phases],
|
|
105
|
+
stopOnFailure: handler.stopOnFailure,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const outputs = processInput(execution, startInput);
|
|
110
|
+
handleOutputs(outputs, execution);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
onPhasedItemEvent(event: Event, itemKey: string): void {
|
|
114
|
+
const executionKey = itemToExecution.get(itemKey);
|
|
115
|
+
if (!executionKey) return;
|
|
116
|
+
|
|
117
|
+
const execution = executions.get(executionKey);
|
|
118
|
+
if (!execution) return;
|
|
119
|
+
|
|
120
|
+
const isFailure = event.type === execution.handler.completion.failureEvent.name;
|
|
121
|
+
|
|
122
|
+
const input: PhasedInput = isFailure
|
|
123
|
+
? { type: 'ItemFailed', data: { itemKey, error: event.data } }
|
|
124
|
+
: { type: 'ItemCompleted', data: { itemKey, result: event.data } };
|
|
125
|
+
|
|
126
|
+
const outputs = processInput(execution, input);
|
|
127
|
+
handleOutputs(outputs, execution);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|