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,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const {
|
|
6
|
+
executeAgent,
|
|
7
|
+
executeParallel,
|
|
8
|
+
executeInterrupt,
|
|
9
|
+
executeTeam,
|
|
10
|
+
NotImplementedError
|
|
11
|
+
} = require('../src/executors');
|
|
12
|
+
|
|
13
|
+
function makeContext(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
inputs: { task: 'test task', profile: 'default', complexity: 'medium', ...overrides.inputs },
|
|
16
|
+
nodes: { ...overrides.nodes },
|
|
17
|
+
steps: { ...overrides.steps },
|
|
18
|
+
state: { ...overrides.state },
|
|
19
|
+
_iterations: {}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('executeAgent', () => {
|
|
24
|
+
it('invokes callback with resolved inputs and stores output', async () => {
|
|
25
|
+
let capturedConfig = null;
|
|
26
|
+
const callbacks = {
|
|
27
|
+
onAgent: async (config) => {
|
|
28
|
+
capturedConfig = config;
|
|
29
|
+
return { files: ['auth.js'], summary: 'Added auth' };
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const ctx = makeContext({ inputs: { task: 'add auth' } });
|
|
34
|
+
const result = await executeAgent(
|
|
35
|
+
{
|
|
36
|
+
id: 'merge_analysis',
|
|
37
|
+
type: 'agent',
|
|
38
|
+
agent: 'elsabro-analyst',
|
|
39
|
+
config: { model: 'opus' },
|
|
40
|
+
inputs: { task: '{{inputs.task}}', extra: 'data' },
|
|
41
|
+
next: 'next_node'
|
|
42
|
+
},
|
|
43
|
+
ctx,
|
|
44
|
+
callbacks
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
assert.equal(result.next, 'next_node');
|
|
48
|
+
assert.equal(capturedConfig.id, 'merge_analysis');
|
|
49
|
+
assert.equal(capturedConfig.agent, 'elsabro-analyst');
|
|
50
|
+
assert.equal(capturedConfig.inputs.task, 'add auth');
|
|
51
|
+
assert.equal(capturedConfig.inputs.extra, 'data');
|
|
52
|
+
assert.ok(ctx.nodes.merge_analysis.outputs);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles missing callback gracefully', async () => {
|
|
56
|
+
const ctx = makeContext();
|
|
57
|
+
const result = await executeAgent(
|
|
58
|
+
{
|
|
59
|
+
id: 'agent1',
|
|
60
|
+
type: 'agent',
|
|
61
|
+
agent: 'test',
|
|
62
|
+
next: 'done'
|
|
63
|
+
},
|
|
64
|
+
ctx,
|
|
65
|
+
{}
|
|
66
|
+
);
|
|
67
|
+
assert.equal(result.next, 'done');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('executeParallel', () => {
|
|
72
|
+
it('resolves branch inputs and invokes onParallel', async () => {
|
|
73
|
+
let capturedParallel = null;
|
|
74
|
+
const callbacks = {
|
|
75
|
+
onParallel: async (config) => {
|
|
76
|
+
capturedParallel = config;
|
|
77
|
+
return config.branches.map(b => ({ id: b.id, result: `done-${b.id}` }));
|
|
78
|
+
},
|
|
79
|
+
onTeamRequired: async () => {}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const ctx = makeContext({ inputs: { task: 'test' } });
|
|
83
|
+
const result = await executeParallel(
|
|
84
|
+
{
|
|
85
|
+
id: 'standard_analyze',
|
|
86
|
+
type: 'parallel',
|
|
87
|
+
branches: [
|
|
88
|
+
{ id: 'explore', agent: 'Explore', inputs: { task: '{{inputs.task}}' } },
|
|
89
|
+
{ id: 'plan', agent: 'Plan', inputs: { task: '{{inputs.task}}' } },
|
|
90
|
+
{ id: 'debt', agent: 'general-purpose', inputs: {} }
|
|
91
|
+
],
|
|
92
|
+
joinType: 'all',
|
|
93
|
+
next: 'merge'
|
|
94
|
+
},
|
|
95
|
+
ctx,
|
|
96
|
+
callbacks
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assert.equal(result.next, 'merge');
|
|
100
|
+
assert.equal(capturedParallel.joinType, 'all');
|
|
101
|
+
assert.equal(capturedParallel.branches.length, 3);
|
|
102
|
+
assert.equal(capturedParallel.branches[0].inputs.task, 'test');
|
|
103
|
+
assert.ok(result.outputs.branches.explore);
|
|
104
|
+
assert.ok(result.outputs.branches.plan);
|
|
105
|
+
assert.ok(result.outputs.branches.debt);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('calls onTeamRequired when branches >= 2', async () => {
|
|
109
|
+
let teamCalled = false;
|
|
110
|
+
const callbacks = {
|
|
111
|
+
onTeamRequired: async () => { teamCalled = true; },
|
|
112
|
+
onParallel: async ({ branches }) => branches.map(() => ({ result: 'ok' }))
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const ctx = makeContext();
|
|
116
|
+
await executeParallel(
|
|
117
|
+
{
|
|
118
|
+
id: 'par',
|
|
119
|
+
type: 'parallel',
|
|
120
|
+
branches: [{ id: 'a' }, { id: 'b' }],
|
|
121
|
+
next: 'done'
|
|
122
|
+
},
|
|
123
|
+
ctx,
|
|
124
|
+
callbacks
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.equal(teamCalled, true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('triggers onMaxIterations after reaching limit', async () => {
|
|
131
|
+
const callbacks = {
|
|
132
|
+
onParallel: async ({ branches }) => branches.map(() => ({ result: 'ok' }))
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const ctx = makeContext();
|
|
136
|
+
|
|
137
|
+
// First 2 iterations should go to 'next'
|
|
138
|
+
const result1 = await executeParallel(
|
|
139
|
+
{
|
|
140
|
+
id: 'fix_issues',
|
|
141
|
+
type: 'parallel',
|
|
142
|
+
branches: [{ id: 'a' }],
|
|
143
|
+
next: 'quality_gate',
|
|
144
|
+
maxIterations: 3,
|
|
145
|
+
onMaxIterations: 'interrupt_manual_fix'
|
|
146
|
+
},
|
|
147
|
+
ctx,
|
|
148
|
+
callbacks
|
|
149
|
+
);
|
|
150
|
+
assert.equal(result1.next, 'quality_gate');
|
|
151
|
+
|
|
152
|
+
const result2 = await executeParallel(
|
|
153
|
+
{
|
|
154
|
+
id: 'fix_issues',
|
|
155
|
+
type: 'parallel',
|
|
156
|
+
branches: [{ id: 'a' }],
|
|
157
|
+
next: 'quality_gate',
|
|
158
|
+
maxIterations: 3,
|
|
159
|
+
onMaxIterations: 'interrupt_manual_fix'
|
|
160
|
+
},
|
|
161
|
+
ctx,
|
|
162
|
+
callbacks
|
|
163
|
+
);
|
|
164
|
+
assert.equal(result2.next, 'quality_gate');
|
|
165
|
+
|
|
166
|
+
// Third iteration should hit maxIterations
|
|
167
|
+
const result3 = await executeParallel(
|
|
168
|
+
{
|
|
169
|
+
id: 'fix_issues',
|
|
170
|
+
type: 'parallel',
|
|
171
|
+
branches: [{ id: 'a' }],
|
|
172
|
+
next: 'quality_gate',
|
|
173
|
+
maxIterations: 3,
|
|
174
|
+
onMaxIterations: 'interrupt_manual_fix'
|
|
175
|
+
},
|
|
176
|
+
ctx,
|
|
177
|
+
callbacks
|
|
178
|
+
);
|
|
179
|
+
assert.equal(result3.next, 'interrupt_manual_fix');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('executeInterrupt', () => {
|
|
184
|
+
it('routes based on user selection', async () => {
|
|
185
|
+
const callbacks = {
|
|
186
|
+
onInterrupt: async (display) => {
|
|
187
|
+
assert.equal(display.nodeId, 'int1');
|
|
188
|
+
assert.ok(display.display.title);
|
|
189
|
+
return 'approve';
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const result = await executeInterrupt(
|
|
194
|
+
{
|
|
195
|
+
id: 'int1',
|
|
196
|
+
type: 'interrupt',
|
|
197
|
+
reason: 'Plan needs approval',
|
|
198
|
+
display: {
|
|
199
|
+
title: 'Review Plan',
|
|
200
|
+
options: [
|
|
201
|
+
{ id: 'approve', label: 'Approve' },
|
|
202
|
+
{ id: 'reject', label: 'Reject' }
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
routes: { approve: 'implement', reject: 'cancel' }
|
|
206
|
+
},
|
|
207
|
+
makeContext(),
|
|
208
|
+
callbacks
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.equal(result.next, 'implement');
|
|
212
|
+
assert.equal(result.outputs.selection, 'approve');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('throws when selected option has no route', async () => {
|
|
216
|
+
const callbacks = {
|
|
217
|
+
onInterrupt: async () => 'unknown_option'
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
executeInterrupt(
|
|
222
|
+
{
|
|
223
|
+
id: 'int',
|
|
224
|
+
type: 'interrupt',
|
|
225
|
+
display: { options: [] },
|
|
226
|
+
routes: { approve: 'a' }
|
|
227
|
+
},
|
|
228
|
+
makeContext(),
|
|
229
|
+
callbacks
|
|
230
|
+
),
|
|
231
|
+
/no matching route/
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('picks first option when no callback provided', async () => {
|
|
236
|
+
const result = await executeInterrupt(
|
|
237
|
+
{
|
|
238
|
+
id: 'int',
|
|
239
|
+
type: 'interrupt',
|
|
240
|
+
display: {
|
|
241
|
+
title: 'Choose',
|
|
242
|
+
options: [{ id: 'approve', label: 'Approve' }]
|
|
243
|
+
},
|
|
244
|
+
routes: { approve: 'next' }
|
|
245
|
+
},
|
|
246
|
+
makeContext(),
|
|
247
|
+
{}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
assert.equal(result.next, 'next');
|
|
251
|
+
assert.equal(result.outputs.selection, 'approve');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('executeTeam', () => {
|
|
256
|
+
it('throws NotImplementedError for deprecated team nodes', async () => {
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
executeTeam(
|
|
259
|
+
{
|
|
260
|
+
id: 'teams_spawn',
|
|
261
|
+
type: 'team',
|
|
262
|
+
runtime_status: 'not_implemented',
|
|
263
|
+
gaps: ['Deprecated — Agent Teams now handled inline']
|
|
264
|
+
},
|
|
265
|
+
makeContext(),
|
|
266
|
+
{}
|
|
267
|
+
),
|
|
268
|
+
(err) => {
|
|
269
|
+
assert.equal(err.name, 'NotImplementedError');
|
|
270
|
+
assert.equal(err.nodeId, 'teams_spawn');
|
|
271
|
+
assert.ok(err.message.includes('Deprecated'));
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('delegates to callback if team is not deprecated', async () => {
|
|
278
|
+
let teamCalled = false;
|
|
279
|
+
const callbacks = {
|
|
280
|
+
onTeam: async (node) => {
|
|
281
|
+
teamCalled = true;
|
|
282
|
+
return { teamResult: 'ok' };
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const result = await executeTeam(
|
|
287
|
+
{
|
|
288
|
+
id: 'custom_team',
|
|
289
|
+
type: 'team',
|
|
290
|
+
runtime_status: 'implemented',
|
|
291
|
+
next: 'done'
|
|
292
|
+
},
|
|
293
|
+
makeContext(),
|
|
294
|
+
callbacks
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
assert.equal(teamCalled, true);
|
|
298
|
+
assert.equal(result.next, 'done');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const {
|
|
6
|
+
executeEntry,
|
|
7
|
+
executeExit,
|
|
8
|
+
executeCondition,
|
|
9
|
+
executeRouter,
|
|
10
|
+
executeSequence,
|
|
11
|
+
getExecutor,
|
|
12
|
+
checkRuntimeStatus,
|
|
13
|
+
NotImplementedError,
|
|
14
|
+
ExecutorError
|
|
15
|
+
} = require('../src/executors');
|
|
16
|
+
|
|
17
|
+
// Shared empty context
|
|
18
|
+
function makeContext(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
inputs: { task: 'test task', profile: 'default', complexity: 'medium', ...overrides.inputs },
|
|
21
|
+
nodes: { ...overrides.nodes },
|
|
22
|
+
steps: { ...overrides.steps },
|
|
23
|
+
state: { ...overrides.state },
|
|
24
|
+
_iterations: {}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('checkRuntimeStatus', () => {
|
|
29
|
+
it('does nothing for implemented nodes', () => {
|
|
30
|
+
assert.doesNotThrow(() =>
|
|
31
|
+
checkRuntimeStatus({ id: 'a', runtime_status: 'implemented' })
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does nothing for partial nodes', () => {
|
|
36
|
+
assert.doesNotThrow(() =>
|
|
37
|
+
checkRuntimeStatus({ id: 'a', runtime_status: 'partial' })
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws NotImplementedError for not_implemented', () => {
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => checkRuntimeStatus({
|
|
44
|
+
id: 'interview_yolo',
|
|
45
|
+
runtime_status: 'not_implemented',
|
|
46
|
+
gaps: ['No interview command exists']
|
|
47
|
+
}),
|
|
48
|
+
(err) => {
|
|
49
|
+
assert.equal(err.name, 'NotImplementedError');
|
|
50
|
+
assert.ok(err.message.includes('interview_yolo'));
|
|
51
|
+
assert.ok(err.message.includes('No interview command exists'));
|
|
52
|
+
assert.equal(err.nodeId, 'interview_yolo');
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('executeEntry', () => {
|
|
60
|
+
it('returns next node with empty outputs', async () => {
|
|
61
|
+
const result = await executeEntry(
|
|
62
|
+
{ id: 'start', type: 'entry', next: 'skill_discovery' },
|
|
63
|
+
makeContext(),
|
|
64
|
+
{}
|
|
65
|
+
);
|
|
66
|
+
assert.equal(result.next, 'skill_discovery');
|
|
67
|
+
assert.deepEqual(result.outputs, {});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('executeExit', () => {
|
|
72
|
+
it('resolves output templates and returns null next', async () => {
|
|
73
|
+
const ctx = makeContext({ inputs: { task: 'my task' } });
|
|
74
|
+
const result = await executeExit(
|
|
75
|
+
{
|
|
76
|
+
id: 'end',
|
|
77
|
+
type: 'exit',
|
|
78
|
+
status: 'success',
|
|
79
|
+
outputs: { success: true, task: '{{inputs.task}}' }
|
|
80
|
+
},
|
|
81
|
+
ctx,
|
|
82
|
+
{}
|
|
83
|
+
);
|
|
84
|
+
assert.equal(result.next, null);
|
|
85
|
+
assert.equal(result.outputs.success, true);
|
|
86
|
+
assert.equal(result.outputs.task, 'my task');
|
|
87
|
+
assert.equal(result.status, 'success');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles exit with no outputs', async () => {
|
|
91
|
+
const result = await executeExit(
|
|
92
|
+
{ id: 'end', type: 'exit', status: 'cancelled' },
|
|
93
|
+
makeContext(),
|
|
94
|
+
{}
|
|
95
|
+
);
|
|
96
|
+
assert.equal(result.next, null);
|
|
97
|
+
assert.deepEqual(result.outputs, {});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('executeCondition', () => {
|
|
102
|
+
it('routes to true branch when condition is true', async () => {
|
|
103
|
+
const ctx = makeContext({
|
|
104
|
+
nodes: {
|
|
105
|
+
quality_gate: { outputs: { tests: { exitCode: 0 } } }
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const result = await executeCondition(
|
|
109
|
+
{
|
|
110
|
+
id: 'check',
|
|
111
|
+
type: 'condition',
|
|
112
|
+
condition: '{{nodes.quality_gate.outputs.tests.exitCode === 0}}',
|
|
113
|
+
true: 'review',
|
|
114
|
+
false: 'fix'
|
|
115
|
+
},
|
|
116
|
+
ctx,
|
|
117
|
+
{}
|
|
118
|
+
);
|
|
119
|
+
assert.equal(result.next, 'review');
|
|
120
|
+
assert.equal(result.outputs.conditionResult, true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('routes to false branch when condition is false', async () => {
|
|
124
|
+
const ctx = makeContext({
|
|
125
|
+
nodes: {
|
|
126
|
+
quality_gate: { outputs: { tests: { exitCode: 1 } } }
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const result = await executeCondition(
|
|
130
|
+
{
|
|
131
|
+
id: 'check',
|
|
132
|
+
type: 'condition',
|
|
133
|
+
condition: '{{nodes.quality_gate.outputs.tests.exitCode === 0}}',
|
|
134
|
+
true: 'review',
|
|
135
|
+
false: 'fix'
|
|
136
|
+
},
|
|
137
|
+
ctx,
|
|
138
|
+
{}
|
|
139
|
+
);
|
|
140
|
+
assert.equal(result.next, 'fix');
|
|
141
|
+
assert.equal(result.outputs.conditionResult, false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('throws on missing condition', async () => {
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
executeCondition({ id: 'c', type: 'condition', true: 'a', false: 'b' }, makeContext(), {}),
|
|
147
|
+
ExecutorError
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('executeRouter', () => {
|
|
153
|
+
it('routes based on input value', async () => {
|
|
154
|
+
const ctx = makeContext({ inputs: { profile: 'yolo' } });
|
|
155
|
+
const result = await executeRouter(
|
|
156
|
+
{
|
|
157
|
+
id: 'router',
|
|
158
|
+
type: 'router',
|
|
159
|
+
condition: '{{inputs.profile}}',
|
|
160
|
+
routes: {
|
|
161
|
+
yolo: 'interview_yolo',
|
|
162
|
+
careful: 'interview_careful',
|
|
163
|
+
default: 'interview_default'
|
|
164
|
+
},
|
|
165
|
+
default: 'interview_default'
|
|
166
|
+
},
|
|
167
|
+
ctx,
|
|
168
|
+
{}
|
|
169
|
+
);
|
|
170
|
+
assert.equal(result.next, 'interview_yolo');
|
|
171
|
+
assert.equal(result.outputs.routeKey, 'yolo');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('falls back to default route when no match', async () => {
|
|
175
|
+
const ctx = makeContext({ inputs: { profile: 'unknown_profile' } });
|
|
176
|
+
const result = await executeRouter(
|
|
177
|
+
{
|
|
178
|
+
id: 'router',
|
|
179
|
+
type: 'router',
|
|
180
|
+
condition: '{{inputs.profile}}',
|
|
181
|
+
routes: {
|
|
182
|
+
yolo: 'interview_yolo',
|
|
183
|
+
careful: 'interview_careful'
|
|
184
|
+
},
|
|
185
|
+
default: 'interview_default'
|
|
186
|
+
},
|
|
187
|
+
ctx,
|
|
188
|
+
{}
|
|
189
|
+
);
|
|
190
|
+
assert.equal(result.next, 'interview_default');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('throws when no route matches and no default', async () => {
|
|
194
|
+
const ctx = makeContext({ inputs: { profile: 'unknown' } });
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
executeRouter(
|
|
197
|
+
{
|
|
198
|
+
id: 'router',
|
|
199
|
+
type: 'router',
|
|
200
|
+
condition: '{{inputs.profile}}',
|
|
201
|
+
routes: { yolo: 'a' }
|
|
202
|
+
},
|
|
203
|
+
ctx,
|
|
204
|
+
{}
|
|
205
|
+
),
|
|
206
|
+
/could not match/
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('executeSequence', () => {
|
|
212
|
+
it('executes steps and resolves outputs', async () => {
|
|
213
|
+
const callbacks = {
|
|
214
|
+
onBash: async (cmd) => ({ output: '{"skills": ["context7"]}', exitCode: 0 })
|
|
215
|
+
};
|
|
216
|
+
const ctx = makeContext();
|
|
217
|
+
const result = await executeSequence(
|
|
218
|
+
{
|
|
219
|
+
id: 'seq',
|
|
220
|
+
type: 'sequence',
|
|
221
|
+
steps: [
|
|
222
|
+
{ action: 'bash', command: 'echo test', as: 'step1' }
|
|
223
|
+
],
|
|
224
|
+
outputs: { result: '{{steps.step1.output.output}}' },
|
|
225
|
+
next: 'next_node'
|
|
226
|
+
},
|
|
227
|
+
ctx,
|
|
228
|
+
callbacks
|
|
229
|
+
);
|
|
230
|
+
assert.equal(result.next, 'next_node');
|
|
231
|
+
assert.equal(result.outputs.result, '{"skills": ["context7"]}');
|
|
232
|
+
assert.ok(ctx.steps.step1); // Step was stored
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('continues on optional step failure', async () => {
|
|
236
|
+
const ctx = makeContext();
|
|
237
|
+
const callbacks = {
|
|
238
|
+
onReadFiles: async () => { throw new Error('file not found'); }
|
|
239
|
+
};
|
|
240
|
+
const result = await executeSequence(
|
|
241
|
+
{
|
|
242
|
+
id: 'seq',
|
|
243
|
+
type: 'sequence',
|
|
244
|
+
steps: [
|
|
245
|
+
{ action: 'read_files', files: ['missing.md'], optional: true, as: 'files' }
|
|
246
|
+
],
|
|
247
|
+
next: 'done'
|
|
248
|
+
},
|
|
249
|
+
ctx,
|
|
250
|
+
callbacks
|
|
251
|
+
);
|
|
252
|
+
assert.equal(result.next, 'done');
|
|
253
|
+
assert.ok(ctx.steps.files.error); // Error was recorded
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('continues on errorPolicy=continue', async () => {
|
|
257
|
+
const ctx = makeContext();
|
|
258
|
+
const callbacks = {
|
|
259
|
+
onBash: async () => { throw new Error('command failed'); }
|
|
260
|
+
};
|
|
261
|
+
const result = await executeSequence(
|
|
262
|
+
{
|
|
263
|
+
id: 'seq',
|
|
264
|
+
type: 'sequence',
|
|
265
|
+
errorPolicy: 'continue',
|
|
266
|
+
steps: [
|
|
267
|
+
{ action: 'bash', command: 'bad', as: 'bad_step' }
|
|
268
|
+
],
|
|
269
|
+
next: 'done'
|
|
270
|
+
},
|
|
271
|
+
ctx,
|
|
272
|
+
callbacks
|
|
273
|
+
);
|
|
274
|
+
assert.equal(result.next, 'done');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('throws on step failure without optional or errorPolicy', async () => {
|
|
278
|
+
const callbacks = {
|
|
279
|
+
onBash: async () => { throw new Error('boom'); }
|
|
280
|
+
};
|
|
281
|
+
await assert.rejects(
|
|
282
|
+
executeSequence(
|
|
283
|
+
{
|
|
284
|
+
id: 'seq',
|
|
285
|
+
type: 'sequence',
|
|
286
|
+
steps: [{ action: 'bash', command: 'fail', as: 'x' }],
|
|
287
|
+
next: 'done'
|
|
288
|
+
},
|
|
289
|
+
makeContext(),
|
|
290
|
+
callbacks
|
|
291
|
+
),
|
|
292
|
+
ExecutorError
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('skips unrecognized step actions gracefully', async () => {
|
|
297
|
+
const ctx = makeContext();
|
|
298
|
+
const result = await executeSequence(
|
|
299
|
+
{
|
|
300
|
+
id: 'seq',
|
|
301
|
+
type: 'sequence',
|
|
302
|
+
steps: [
|
|
303
|
+
{ action: 'learn_patterns', as: 'learn' }
|
|
304
|
+
],
|
|
305
|
+
next: 'done'
|
|
306
|
+
},
|
|
307
|
+
ctx,
|
|
308
|
+
{}
|
|
309
|
+
);
|
|
310
|
+
assert.equal(result.next, 'done');
|
|
311
|
+
assert.ok(ctx.steps.learn.output.skipped);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('getExecutor', () => {
|
|
316
|
+
it('returns executors for all 9 types', () => {
|
|
317
|
+
const types = ['entry', 'exit', 'condition', 'router', 'sequence', 'agent', 'parallel', 'interrupt', 'team'];
|
|
318
|
+
for (const type of types) {
|
|
319
|
+
assert.equal(typeof getExecutor(type), 'function', `Missing executor for "${type}"`);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('throws for unknown type', () => {
|
|
324
|
+
assert.throws(() => getExecutor('subflow'), /No executor/);
|
|
325
|
+
});
|
|
326
|
+
});
|