elsabro 5.2.0 → 6.0.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/agents/elsabro-executor.md +25 -0
- package/agents/elsabro-planner.md +10 -0
- package/bin/install.js +8 -4
- package/commands/elsabro/execute.md +156 -3
- package/commands/elsabro/plan.md +32 -0
- package/commands/elsabro/quick.md +21 -0
- package/commands/elsabro/verify-work.md +29 -0
- package/flow-engine/package.json +13 -0
- package/flow-engine/src/checkpoint.js +112 -0
- package/flow-engine/src/executors.js +406 -0
- package/flow-engine/src/graph.js +129 -0
- package/flow-engine/src/index.js +137 -0
- package/flow-engine/src/runner.js +184 -0
- package/flow-engine/src/template.js +290 -0
- package/flow-engine/tests/executors-complex.test.js +300 -0
- package/flow-engine/tests/executors.test.js +326 -0
- package/flow-engine/tests/graph.test.js +161 -0
- package/flow-engine/tests/integration.test.js +251 -0
- package/flow-engine/tests/runner.test.js +289 -0
- package/flow-engine/tests/template.test.js +272 -0
- package/flows/development-flow.json +134 -1
- package/package.json +4 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { buildGraph, validateGraph, getNode, getNextNodes, GraphError } = require('../src/graph');
|
|
6
|
+
|
|
7
|
+
// Minimal valid flow for testing
|
|
8
|
+
const minimalFlow = {
|
|
9
|
+
id: 'test_flow',
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
config: {},
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: 'start', type: 'entry', next: 'end' },
|
|
14
|
+
{ id: 'end', type: 'exit', status: 'success', outputs: {} }
|
|
15
|
+
]
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('buildGraph', () => {
|
|
19
|
+
it('builds a graph from a valid flow definition', () => {
|
|
20
|
+
const graph = buildGraph(minimalFlow);
|
|
21
|
+
assert.equal(graph.nodes.size, 2);
|
|
22
|
+
assert.equal(graph.entryNode, 'start');
|
|
23
|
+
assert.equal(graph.meta.version, '1.0.0');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws on missing nodes array', () => {
|
|
27
|
+
assert.throws(() => buildGraph({}), GraphError);
|
|
28
|
+
assert.throws(() => buildGraph({ nodes: 'not-array' }), GraphError);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('throws on duplicate node IDs', () => {
|
|
32
|
+
assert.throws(() => buildGraph({
|
|
33
|
+
nodes: [
|
|
34
|
+
{ id: 'start', type: 'entry', next: 'start' },
|
|
35
|
+
{ id: 'start', type: 'exit' }
|
|
36
|
+
]
|
|
37
|
+
}), /Duplicate node id/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws on missing entry node', () => {
|
|
41
|
+
assert.throws(() => buildGraph({
|
|
42
|
+
nodes: [{ id: 'a', type: 'sequence', next: 'a' }]
|
|
43
|
+
}), /No entry node/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('throws on multiple entry nodes', () => {
|
|
47
|
+
assert.throws(() => buildGraph({
|
|
48
|
+
nodes: [
|
|
49
|
+
{ id: 'a', type: 'entry', next: 'b' },
|
|
50
|
+
{ id: 'b', type: 'entry', next: 'a' }
|
|
51
|
+
]
|
|
52
|
+
}), /Multiple entry nodes/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('preserves sync_metadata from flow definition', () => {
|
|
56
|
+
const flow = {
|
|
57
|
+
...minimalFlow,
|
|
58
|
+
sync_metadata: { last_audit: '2026-02-07', audit_result: { total_nodes: 2 } }
|
|
59
|
+
};
|
|
60
|
+
const graph = buildGraph(flow);
|
|
61
|
+
assert.equal(graph.meta.sync_metadata.last_audit, '2026-02-07');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('validateGraph', () => {
|
|
66
|
+
it('validates a correct graph', () => {
|
|
67
|
+
const graph = buildGraph(minimalFlow);
|
|
68
|
+
const result = validateGraph(graph);
|
|
69
|
+
assert.equal(result.valid, true);
|
|
70
|
+
assert.equal(result.errors.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('detects dangling references', () => {
|
|
74
|
+
const graph = buildGraph({
|
|
75
|
+
nodes: [
|
|
76
|
+
{ id: 'start', type: 'entry', next: 'missing_node' }
|
|
77
|
+
]
|
|
78
|
+
});
|
|
79
|
+
// Override entryNode since we forced it
|
|
80
|
+
const result = validateGraph(graph);
|
|
81
|
+
assert.equal(result.valid, false);
|
|
82
|
+
assert.ok(result.errors[0].includes('missing_node'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('validates router routes', () => {
|
|
86
|
+
const graph = buildGraph({
|
|
87
|
+
nodes: [
|
|
88
|
+
{ id: 'start', type: 'entry', next: 'router' },
|
|
89
|
+
{ id: 'router', type: 'router', routes: { a: 'end', b: 'orphan' }, default: 'end' },
|
|
90
|
+
{ id: 'end', type: 'exit', status: 'success', outputs: {} }
|
|
91
|
+
]
|
|
92
|
+
});
|
|
93
|
+
const result = validateGraph(graph);
|
|
94
|
+
assert.equal(result.valid, false);
|
|
95
|
+
assert.ok(result.errors.some(e => e.includes('orphan')));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('validates condition true/false branches', () => {
|
|
99
|
+
const graph = buildGraph({
|
|
100
|
+
nodes: [
|
|
101
|
+
{ id: 'start', type: 'entry', next: 'cond' },
|
|
102
|
+
{ id: 'cond', type: 'condition', true: 'end', false: 'ghost' },
|
|
103
|
+
{ id: 'end', type: 'exit', status: 'success', outputs: {} }
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
const result = validateGraph(graph);
|
|
107
|
+
assert.equal(result.valid, false);
|
|
108
|
+
assert.ok(result.errors.some(e => e.includes('ghost')));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getNode', () => {
|
|
113
|
+
it('returns a node by ID', () => {
|
|
114
|
+
const graph = buildGraph(minimalFlow);
|
|
115
|
+
const node = getNode(graph, 'start');
|
|
116
|
+
assert.equal(node.type, 'entry');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws on missing node ID', () => {
|
|
120
|
+
const graph = buildGraph(minimalFlow);
|
|
121
|
+
assert.throws(() => getNode(graph, 'nope'), /not found/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('getNextNodes', () => {
|
|
126
|
+
it('collects next from a simple node', () => {
|
|
127
|
+
const refs = getNextNodes({ id: 'a', next: 'b' });
|
|
128
|
+
assert.deepEqual(refs, ['b']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('collects from condition node (true/false)', () => {
|
|
132
|
+
const refs = getNextNodes({ id: 'c', type: 'condition', true: 'yes', false: 'no' });
|
|
133
|
+
assert.deepEqual(refs.sort(), ['no', 'yes']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('collects from router routes', () => {
|
|
137
|
+
const refs = getNextNodes({ id: 'r', routes: { a: 'x', b: 'y' }, default: 'y' });
|
|
138
|
+
// 'y' appears in both routes and default — deduplication
|
|
139
|
+
assert.deepEqual(refs.sort(), ['x', 'y']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('collects onMaxIterations and onError', () => {
|
|
143
|
+
const refs = getNextNodes({ id: 'p', next: 'a', onMaxIterations: 'b', onError: 'c' });
|
|
144
|
+
assert.deepEqual(refs.sort(), ['a', 'b', 'c']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns empty for exit node', () => {
|
|
148
|
+
const refs = getNextNodes({ id: 'end', type: 'exit', status: 'success' });
|
|
149
|
+
assert.deepEqual(refs, []);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('real flow loading', () => {
|
|
154
|
+
it('loads the full development-flow.json', () => {
|
|
155
|
+
const flow = require('../../flows/development-flow.json');
|
|
156
|
+
const graph = buildGraph(flow);
|
|
157
|
+
assert.equal(graph.nodes.size, 42);
|
|
158
|
+
assert.equal(graph.entryNode, 'start');
|
|
159
|
+
assert.equal(graph.meta.version, '5.3.0');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { FlowEngine, NotImplementedError } = require('../src/index');
|
|
6
|
+
const { resolveTemplate } = require('../src/template');
|
|
7
|
+
|
|
8
|
+
// Load the REAL flow
|
|
9
|
+
const flow = require('../../flows/development-flow.json');
|
|
10
|
+
|
|
11
|
+
// Mock callbacks that simulate command behavior
|
|
12
|
+
function makeMockCallbacks() {
|
|
13
|
+
const log = [];
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
log,
|
|
17
|
+
onBash: async (cmd) => {
|
|
18
|
+
log.push({ type: 'bash', cmd });
|
|
19
|
+
return { output: '{}', exitCode: 0 };
|
|
20
|
+
},
|
|
21
|
+
onReadFiles: async (files) => {
|
|
22
|
+
log.push({ type: 'read_files', files });
|
|
23
|
+
return { content: 'mock file content' };
|
|
24
|
+
},
|
|
25
|
+
onAgent: async (config) => {
|
|
26
|
+
log.push({ type: 'agent', id: config.id, agent: config.agent });
|
|
27
|
+
return { result: 'mock agent output' };
|
|
28
|
+
},
|
|
29
|
+
onNodeStart: async (id, type) => {
|
|
30
|
+
log.push({ type: 'node_start', id, nodeType: type });
|
|
31
|
+
},
|
|
32
|
+
onNodeComplete: async (id, result) => {
|
|
33
|
+
log.push({ type: 'node_complete', id, next: result.next });
|
|
34
|
+
},
|
|
35
|
+
onCheckpoint: async (data) => {
|
|
36
|
+
log.push({ type: 'checkpoint', node: data.currentNode, next: data.nextNode });
|
|
37
|
+
},
|
|
38
|
+
onInterrupt: async (display) => {
|
|
39
|
+
log.push({ type: 'interrupt', nodeId: display.nodeId });
|
|
40
|
+
// Auto-approve first option
|
|
41
|
+
const options = display.display.options || [];
|
|
42
|
+
return options.length > 0 ? options[0].id : 'approve';
|
|
43
|
+
},
|
|
44
|
+
onParallel: async ({ branches }) => {
|
|
45
|
+
log.push({ type: 'parallel', branchCount: branches.length });
|
|
46
|
+
return branches.map(b => ({ id: b.id, result: 'mock parallel result' }));
|
|
47
|
+
},
|
|
48
|
+
onTeamRequired: async ({ nodeId }) => {
|
|
49
|
+
log.push({ type: 'team_required', nodeId });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('Integration: Graph loading', () => {
|
|
55
|
+
it('loads all 42 nodes from development-flow.json', () => {
|
|
56
|
+
const engine = new FlowEngine();
|
|
57
|
+
engine.loadFlow(flow);
|
|
58
|
+
assert.equal(engine.getNodeCount(), 42);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('finds the entry node at "start"', () => {
|
|
62
|
+
const engine = new FlowEngine();
|
|
63
|
+
engine.loadFlow(flow);
|
|
64
|
+
assert.equal(engine.graph.entryNode, 'start');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('verifies sync_metadata matches audit', () => {
|
|
68
|
+
const engine = new FlowEngine();
|
|
69
|
+
engine.loadFlow(flow);
|
|
70
|
+
const meta = engine.getFlowMetadata();
|
|
71
|
+
assert.equal(meta.sync_metadata.audit_result.total_nodes, 42);
|
|
72
|
+
assert.equal(meta.sync_metadata.audit_result.implemented, 19);
|
|
73
|
+
assert.equal(meta.sync_metadata.audit_result.partial, 3);
|
|
74
|
+
assert.equal(meta.sync_metadata.audit_result.not_implemented, 20);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('counts implemented nodes correctly', () => {
|
|
78
|
+
const engine = new FlowEngine();
|
|
79
|
+
engine.loadFlow(flow);
|
|
80
|
+
const implemented = engine.getNodesWhere(n => n.runtime_status === 'implemented');
|
|
81
|
+
assert.equal(implemented.length, 19);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('counts not_implemented nodes correctly', () => {
|
|
85
|
+
const engine = new FlowEngine();
|
|
86
|
+
engine.loadFlow(flow);
|
|
87
|
+
const notImpl = engine.getNodesWhere(n => n.runtime_status === 'not_implemented');
|
|
88
|
+
assert.equal(notImpl.length, 20);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('counts partial nodes correctly', () => {
|
|
92
|
+
const engine = new FlowEngine();
|
|
93
|
+
engine.loadFlow(flow);
|
|
94
|
+
const partial = engine.getNodesWhere(n => n.runtime_status === 'partial');
|
|
95
|
+
assert.equal(partial.length, 3);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('validates graph has no orphaned references', () => {
|
|
99
|
+
const engine = new FlowEngine();
|
|
100
|
+
// loadFlow already validates — this would throw if invalid
|
|
101
|
+
engine.loadFlow(flow);
|
|
102
|
+
assert.ok(engine.graph);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('Integration: Template resolution with real flow data', () => {
|
|
107
|
+
it('resolves {{inputs.task}}', () => {
|
|
108
|
+
const ctx = { inputs: { task: 'add auth', profile: 'default', complexity: 'medium' } };
|
|
109
|
+
assert.equal(resolveTemplate('{{inputs.task}}', ctx), 'add auth');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('resolves router condition: {{inputs.profile}}', () => {
|
|
113
|
+
const ctx = { inputs: { profile: 'bmad' } };
|
|
114
|
+
assert.equal(resolveTemplate('{{inputs.profile}}', ctx), 'bmad');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('resolves quality_check condition from real flow', () => {
|
|
118
|
+
const ctx = {
|
|
119
|
+
nodes: {
|
|
120
|
+
quality_gate: {
|
|
121
|
+
outputs: {
|
|
122
|
+
tests: { exitCode: 0 },
|
|
123
|
+
typescript: { exitCode: 0 },
|
|
124
|
+
lint: { exitCode: 0 }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const condition = '{{nodes.quality_gate.outputs.tests.exitCode === 0 && nodes.quality_gate.outputs.typescript.exitCode === 0 && nodes.quality_gate.outputs.lint.exitCode === 0}}';
|
|
130
|
+
assert.equal(resolveTemplate(condition, ctx), true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('resolves team config name with slugify from real flow', () => {
|
|
134
|
+
const ctx = { inputs: { task: 'Build User Dashboard' } };
|
|
135
|
+
const template = 'elsabro-{{inputs.task | slugify}}';
|
|
136
|
+
assert.equal(resolveTemplate(template, ctx), 'elsabro-build-user-dashboard');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('resolves fallback from skill discovery outputs', () => {
|
|
140
|
+
const ctx = {
|
|
141
|
+
steps: { discovery: { output: { recommended: { skills: ['context7', 'stitch'] } } } }
|
|
142
|
+
};
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
resolveTemplate('{{steps.discovery.output.recommended.skills || []}}', ctx),
|
|
145
|
+
['context7', 'stitch']
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Integration: Default profile path (stops at interview_default)', () => {
|
|
151
|
+
it('traverses start → skill_discovery → load_context → profile_router → interview_default (NOT_IMPLEMENTED)', async () => {
|
|
152
|
+
const engine = new FlowEngine();
|
|
153
|
+
engine.loadFlow(flow);
|
|
154
|
+
const { log } = makeMockCallbacks();
|
|
155
|
+
const callbacks = makeMockCallbacks();
|
|
156
|
+
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
engine.run(
|
|
159
|
+
{ task: 'test integration', profile: 'default', complexity: 'medium' },
|
|
160
|
+
callbacks
|
|
161
|
+
),
|
|
162
|
+
(err) => {
|
|
163
|
+
assert.equal(err.name, 'NotImplementedError');
|
|
164
|
+
assert.ok(err.message.includes('interview_default'));
|
|
165
|
+
assert.ok(err.message.includes('not yet implemented'));
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Verify the traversal path via callbacks log
|
|
171
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
172
|
+
assert.deepEqual(nodeStarts, ['start', 'skill_discovery', 'load_context', 'profile_router', 'interview_default']);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Integration: Yolo profile path (stops at interview_yolo)', () => {
|
|
177
|
+
it('routes to yolo interview via profile_router', async () => {
|
|
178
|
+
const engine = new FlowEngine();
|
|
179
|
+
engine.loadFlow(flow);
|
|
180
|
+
const callbacks = makeMockCallbacks();
|
|
181
|
+
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
engine.run(
|
|
184
|
+
{ task: 'quick fix', profile: 'yolo', complexity: 'low' },
|
|
185
|
+
callbacks
|
|
186
|
+
),
|
|
187
|
+
(err) => {
|
|
188
|
+
assert.ok(err.message.includes('interview_yolo'));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
194
|
+
assert.deepEqual(nodeStarts, ['start', 'skill_discovery', 'load_context', 'profile_router', 'interview_yolo']);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('Integration: BMAD profile path (stops at bmad_interview_analysis)', () => {
|
|
199
|
+
it('routes to BMAD interview via profile_router', async () => {
|
|
200
|
+
const engine = new FlowEngine();
|
|
201
|
+
engine.loadFlow(flow);
|
|
202
|
+
const callbacks = makeMockCallbacks();
|
|
203
|
+
|
|
204
|
+
await assert.rejects(
|
|
205
|
+
engine.run(
|
|
206
|
+
{ task: 'build app', profile: 'bmad', complexity: 'high' },
|
|
207
|
+
callbacks
|
|
208
|
+
),
|
|
209
|
+
(err) => {
|
|
210
|
+
assert.ok(err.message.includes('bmad_interview_analysis'));
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const nodeStarts = callbacks.log.filter(e => e.type === 'node_start').map(e => e.id);
|
|
216
|
+
assert.deepEqual(nodeStarts, ['start', 'skill_discovery', 'load_context', 'profile_router', 'bmad_interview_analysis']);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('Integration: Node type coverage', () => {
|
|
221
|
+
it('has executors for all node types in the flow', () => {
|
|
222
|
+
const engine = new FlowEngine();
|
|
223
|
+
engine.loadFlow(flow);
|
|
224
|
+
const { getExecutor } = require('../src/executors');
|
|
225
|
+
|
|
226
|
+
const types = new Set();
|
|
227
|
+
for (const node of engine.graph.nodes.values()) {
|
|
228
|
+
types.add(node.type);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// All types in the flow should have executors
|
|
232
|
+
for (const type of types) {
|
|
233
|
+
assert.doesNotThrow(
|
|
234
|
+
() => getExecutor(type),
|
|
235
|
+
`No executor for node type "${type}" used in flow`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Verify all 9 types are present
|
|
240
|
+
assert.ok(types.has('entry'));
|
|
241
|
+
assert.ok(types.has('exit'));
|
|
242
|
+
assert.ok(types.has('condition'));
|
|
243
|
+
assert.ok(types.has('router'));
|
|
244
|
+
assert.ok(types.has('sequence'));
|
|
245
|
+
assert.ok(types.has('agent'));
|
|
246
|
+
assert.ok(types.has('parallel'));
|
|
247
|
+
assert.ok(types.has('interrupt'));
|
|
248
|
+
assert.ok(types.has('team'));
|
|
249
|
+
assert.equal(types.size, 9);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { FlowEngine, NotImplementedError, CheckpointManager } = require('../src/index');
|
|
6
|
+
const { serializeContext, deserializeContext } = require('../src/runner');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// ---------- Mini flow definitions ----------
|
|
12
|
+
|
|
13
|
+
const miniFlow = {
|
|
14
|
+
id: 'mini',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
config: {},
|
|
17
|
+
inputs: {},
|
|
18
|
+
nodes: [
|
|
19
|
+
{ id: 'start', type: 'entry', runtime_status: 'implemented', next: 'check' },
|
|
20
|
+
{
|
|
21
|
+
id: 'check',
|
|
22
|
+
type: 'condition',
|
|
23
|
+
runtime_status: 'implemented',
|
|
24
|
+
condition: '{{inputs.go}}',
|
|
25
|
+
true: 'end_ok',
|
|
26
|
+
false: 'end_cancel'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'end_ok',
|
|
30
|
+
type: 'exit',
|
|
31
|
+
runtime_status: 'implemented',
|
|
32
|
+
status: 'success',
|
|
33
|
+
outputs: { result: 'completed', task: '{{inputs.task}}' }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'end_cancel',
|
|
37
|
+
type: 'exit',
|
|
38
|
+
runtime_status: 'implemented',
|
|
39
|
+
status: 'cancelled',
|
|
40
|
+
outputs: { result: 'cancelled' }
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const flowWithNotImplemented = {
|
|
46
|
+
id: 'blocked',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
config: {},
|
|
49
|
+
nodes: [
|
|
50
|
+
{ id: 'start', type: 'entry', runtime_status: 'implemented', next: 'blocker' },
|
|
51
|
+
{
|
|
52
|
+
id: 'blocker',
|
|
53
|
+
type: 'agent',
|
|
54
|
+
runtime_status: 'not_implemented',
|
|
55
|
+
gaps: ['No interview command exists'],
|
|
56
|
+
next: 'end'
|
|
57
|
+
},
|
|
58
|
+
{ id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: {} }
|
|
59
|
+
]
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const flowWithSequence = {
|
|
63
|
+
id: 'seq_flow',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
config: {},
|
|
66
|
+
nodes: [
|
|
67
|
+
{ id: 'start', type: 'entry', runtime_status: 'implemented', next: 'seq' },
|
|
68
|
+
{
|
|
69
|
+
id: 'seq',
|
|
70
|
+
type: 'sequence',
|
|
71
|
+
runtime_status: 'implemented',
|
|
72
|
+
errorPolicy: 'continue',
|
|
73
|
+
steps: [
|
|
74
|
+
{ action: 'bash', command: 'echo hello', as: 'greeting' }
|
|
75
|
+
],
|
|
76
|
+
outputs: { msg: '{{steps.greeting.output.output}}' },
|
|
77
|
+
next: 'end'
|
|
78
|
+
},
|
|
79
|
+
{ id: 'end', type: 'exit', runtime_status: 'implemented', status: 'success', outputs: { final: '{{nodes.seq.outputs.msg}}' } }
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe('runFlow — basic traversal', () => {
|
|
84
|
+
it('traverses entry → condition(true) → exit(success)', async () => {
|
|
85
|
+
const engine = new FlowEngine();
|
|
86
|
+
engine.loadFlow(miniFlow);
|
|
87
|
+
|
|
88
|
+
const nodesStarted = [];
|
|
89
|
+
const nodesCompleted = [];
|
|
90
|
+
|
|
91
|
+
const result = await engine.run(
|
|
92
|
+
{ task: 'test', go: true },
|
|
93
|
+
{
|
|
94
|
+
onNodeStart: async (id, type) => nodesStarted.push(id),
|
|
95
|
+
onNodeComplete: async (id) => nodesCompleted.push(id)
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assert.equal(result.success, true);
|
|
100
|
+
assert.equal(result.outputs.result, 'completed');
|
|
101
|
+
assert.equal(result.outputs.task, 'test');
|
|
102
|
+
assert.deepEqual(result.nodesVisited, ['start', 'check', 'end_ok']);
|
|
103
|
+
assert.deepEqual(nodesStarted, ['start', 'check', 'end_ok']);
|
|
104
|
+
assert.deepEqual(nodesCompleted, ['start', 'check', 'end_ok']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('traverses entry → condition(false) → exit(cancelled)', async () => {
|
|
108
|
+
const engine = new FlowEngine();
|
|
109
|
+
engine.loadFlow(miniFlow);
|
|
110
|
+
|
|
111
|
+
const result = await engine.run({ task: 'test', go: false }, {});
|
|
112
|
+
|
|
113
|
+
assert.equal(result.success, false);
|
|
114
|
+
assert.equal(result.outputs.result, 'cancelled');
|
|
115
|
+
assert.deepEqual(result.nodesVisited, ['start', 'check', 'end_cancel']);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('runFlow — not_implemented handling', () => {
|
|
120
|
+
it('throws NotImplementedError with node info', async () => {
|
|
121
|
+
const engine = new FlowEngine();
|
|
122
|
+
engine.loadFlow(flowWithNotImplemented);
|
|
123
|
+
|
|
124
|
+
const allCheckpoints = [];
|
|
125
|
+
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
engine.run({}, {
|
|
128
|
+
onCheckpoint: async (data) => { allCheckpoints.push(data); }
|
|
129
|
+
}),
|
|
130
|
+
(err) => {
|
|
131
|
+
assert.equal(err.name, 'NotImplementedError');
|
|
132
|
+
assert.ok(err.message.includes('blocker'));
|
|
133
|
+
assert.ok(err.message.includes('No interview command exists'));
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Should have checkpoint from start node + blocked checkpoint
|
|
139
|
+
assert.ok(allCheckpoints.length >= 1);
|
|
140
|
+
const lastCheckpoint = allCheckpoints[allCheckpoints.length - 1];
|
|
141
|
+
assert.equal(lastCheckpoint.stoppedAt, 'blocker');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('runFlow — checkpoint saving', () => {
|
|
146
|
+
it('saves a checkpoint after each node', async () => {
|
|
147
|
+
const engine = new FlowEngine();
|
|
148
|
+
engine.loadFlow(miniFlow);
|
|
149
|
+
|
|
150
|
+
const checkpoints = [];
|
|
151
|
+
|
|
152
|
+
await engine.run(
|
|
153
|
+
{ task: 'cp-test', go: true },
|
|
154
|
+
{ onCheckpoint: async (data) => checkpoints.push(data) }
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// 3 nodes → 3 checkpoints
|
|
158
|
+
assert.equal(checkpoints.length, 3);
|
|
159
|
+
assert.equal(checkpoints[0].currentNode, 'start');
|
|
160
|
+
assert.equal(checkpoints[0].nextNode, 'check');
|
|
161
|
+
assert.equal(checkpoints[1].currentNode, 'check');
|
|
162
|
+
assert.equal(checkpoints[1].nextNode, 'end_ok');
|
|
163
|
+
assert.equal(checkpoints[2].currentNode, 'end_ok');
|
|
164
|
+
assert.equal(checkpoints[2].nextNode, null);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('runFlow — resume from checkpoint', () => {
|
|
169
|
+
it('resumes from a checkpoint at a specific node', async () => {
|
|
170
|
+
const engine = new FlowEngine();
|
|
171
|
+
engine.loadFlow(miniFlow);
|
|
172
|
+
|
|
173
|
+
// Create a checkpoint as if we stopped after 'start'
|
|
174
|
+
const checkpoint = {
|
|
175
|
+
nextNode: 'check',
|
|
176
|
+
context: {
|
|
177
|
+
inputs: { task: 'resumed', go: true },
|
|
178
|
+
nodes: { start: { outputs: {} } },
|
|
179
|
+
steps: {},
|
|
180
|
+
state: {},
|
|
181
|
+
_iterations: {}
|
|
182
|
+
},
|
|
183
|
+
nodesVisited: ['start']
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await engine.resume(checkpoint, {});
|
|
187
|
+
|
|
188
|
+
assert.equal(result.success, true);
|
|
189
|
+
assert.equal(result.outputs.task, 'resumed');
|
|
190
|
+
assert.deepEqual(result.nodesVisited, ['start', 'check', 'end_ok']);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('runFlow — sequence with callbacks', () => {
|
|
195
|
+
it('executes a sequence step and passes output through', async () => {
|
|
196
|
+
const engine = new FlowEngine();
|
|
197
|
+
engine.loadFlow(flowWithSequence);
|
|
198
|
+
|
|
199
|
+
const result = await engine.run(
|
|
200
|
+
{},
|
|
201
|
+
{
|
|
202
|
+
onBash: async (cmd) => ({ output: 'hello world', exitCode: 0 })
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
assert.equal(result.success, true);
|
|
207
|
+
assert.equal(result.outputs.final, 'hello world');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('serializeContext / deserializeContext', () => {
|
|
212
|
+
it('round-trips a context through serialize/deserialize', () => {
|
|
213
|
+
const original = {
|
|
214
|
+
inputs: { task: 'test' },
|
|
215
|
+
nodes: { a: { outputs: { x: 1 } } },
|
|
216
|
+
steps: { s: { output: 'data' } },
|
|
217
|
+
state: { counter: 5 },
|
|
218
|
+
_iterations: { a: 2 }
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const serialized = serializeContext(original);
|
|
222
|
+
const deserialized = deserializeContext(serialized);
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(deserialized.inputs, original.inputs);
|
|
225
|
+
assert.deepEqual(deserialized.nodes, original.nodes);
|
|
226
|
+
assert.deepEqual(deserialized.steps, original.steps);
|
|
227
|
+
assert.deepEqual(deserialized.state, original.state);
|
|
228
|
+
assert.deepEqual(deserialized._iterations, original._iterations);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('CheckpointManager', () => {
|
|
233
|
+
const tmpDir = path.join(os.tmpdir(), `elsabro-test-cp-${Date.now()}`);
|
|
234
|
+
|
|
235
|
+
it('saves and loads a checkpoint', () => {
|
|
236
|
+
const mgr = new CheckpointManager({ dir: tmpDir });
|
|
237
|
+
const filename = mgr.save('test_flow', {
|
|
238
|
+
currentNode: 'start',
|
|
239
|
+
nextNode: 'check',
|
|
240
|
+
context: { inputs: { task: 'save test' } }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
assert.ok(filename.startsWith('test_flow-'));
|
|
244
|
+
assert.ok(filename.endsWith('.json'));
|
|
245
|
+
|
|
246
|
+
const loaded = mgr.load('test_flow');
|
|
247
|
+
assert.equal(loaded.flowId, 'test_flow');
|
|
248
|
+
assert.equal(loaded.currentNode, 'start');
|
|
249
|
+
assert.equal(loaded.nextNode, 'check');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('lists checkpoints in reverse chronological order', () => {
|
|
253
|
+
const mgr = new CheckpointManager({ dir: tmpDir });
|
|
254
|
+
mgr.save('list_flow', { currentNode: 'a' });
|
|
255
|
+
mgr.save('list_flow', { currentNode: 'b' });
|
|
256
|
+
|
|
257
|
+
const list = mgr.list('list_flow');
|
|
258
|
+
assert.equal(list.length, 2);
|
|
259
|
+
assert.ok(list[0].timestamp >= list[1].timestamp);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('cleans old checkpoints keeping last N', () => {
|
|
263
|
+
const cleanDir = path.join(os.tmpdir(), `elsabro-clean-${Date.now()}`);
|
|
264
|
+
const mgr = new CheckpointManager({ dir: cleanDir });
|
|
265
|
+
|
|
266
|
+
for (let i = 0; i < 8; i++) {
|
|
267
|
+
mgr.save('clean_flow', { currentNode: `node_${i}` });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const removed = mgr.clean('clean_flow', 3);
|
|
271
|
+
assert.equal(removed, 5);
|
|
272
|
+
assert.equal(mgr.list('clean_flow').length, 3);
|
|
273
|
+
|
|
274
|
+
// Cleanup
|
|
275
|
+
fs.rmSync(cleanDir, { recursive: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('returns null when no checkpoint exists', () => {
|
|
279
|
+
const mgr = new CheckpointManager({ dir: path.join(os.tmpdir(), 'nonexistent-dir-xyz') });
|
|
280
|
+
assert.equal(mgr.load('missing_flow'), null);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Cleanup
|
|
284
|
+
it('cleanup temp dir', () => {
|
|
285
|
+
if (fs.existsSync(tmpDir)) {
|
|
286
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|