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,406 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Node executors for the ELSABRO flow engine.
|
|
5
|
+
*
|
|
6
|
+
* Each executor: async execute{Type}(node, context, callbacks)
|
|
7
|
+
* → { next: nodeId|null, outputs: {}, status?: string }
|
|
8
|
+
*
|
|
9
|
+
* 9 node types total:
|
|
10
|
+
* Simple (Wave 2): entry, exit, condition, router, sequence
|
|
11
|
+
* Complex (Wave 3): agent, parallel, interrupt, team
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { resolveTemplate, resolveExpression } = require('./template');
|
|
15
|
+
|
|
16
|
+
// ---------- Error types ----------
|
|
17
|
+
|
|
18
|
+
class NotImplementedError extends Error {
|
|
19
|
+
constructor(nodeId, gaps) {
|
|
20
|
+
const gapText = gaps ? gaps.join('; ') : 'No details available';
|
|
21
|
+
super(`Node "${nodeId}" is not yet implemented. Gaps: ${gapText}`);
|
|
22
|
+
this.name = 'NotImplementedError';
|
|
23
|
+
this.nodeId = nodeId;
|
|
24
|
+
this.gaps = gaps || [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class ExecutorError extends Error {
|
|
29
|
+
constructor(nodeId, message) {
|
|
30
|
+
super(`Executor error in node "${nodeId}": ${message}`);
|
|
31
|
+
this.name = 'ExecutorError';
|
|
32
|
+
this.nodeId = nodeId;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- Runtime status guard ----------
|
|
37
|
+
|
|
38
|
+
function checkRuntimeStatus(node) {
|
|
39
|
+
if (node.runtime_status === 'not_implemented') {
|
|
40
|
+
throw new NotImplementedError(node.id, node.gaps);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------- Simple Executors ----------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Entry node: no-op, just transitions to next.
|
|
48
|
+
*/
|
|
49
|
+
async function executeEntry(node, context, callbacks) {
|
|
50
|
+
return { next: node.next, outputs: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exit node: resolve output templates, terminate flow.
|
|
55
|
+
*/
|
|
56
|
+
async function executeExit(node, context, callbacks) {
|
|
57
|
+
const resolvedOutputs = node.outputs
|
|
58
|
+
? resolveTemplate(node.outputs, context)
|
|
59
|
+
: {};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
next: null,
|
|
63
|
+
outputs: resolvedOutputs,
|
|
64
|
+
status: node.status || 'completed'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Condition node: evaluate condition, route to true/false branch.
|
|
70
|
+
*/
|
|
71
|
+
async function executeCondition(node, context, callbacks) {
|
|
72
|
+
if (!node.condition) {
|
|
73
|
+
throw new ExecutorError(node.id, 'Condition node has no "condition" field');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const conditionStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
|
|
77
|
+
? node.condition
|
|
78
|
+
: `{{${node.condition}}}`;
|
|
79
|
+
|
|
80
|
+
const result = resolveTemplate(conditionStr, context);
|
|
81
|
+
|
|
82
|
+
const nextNode = result ? node.true : node.false;
|
|
83
|
+
|
|
84
|
+
if (!nextNode) {
|
|
85
|
+
throw new ExecutorError(
|
|
86
|
+
node.id,
|
|
87
|
+
`Condition evaluated to ${!!result} but no "${result ? 'true' : 'false'}" branch defined`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { next: nextNode, outputs: { conditionResult: !!result } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Router node: evaluate condition as a string, match against routes map.
|
|
96
|
+
*/
|
|
97
|
+
async function executeRouter(node, context, callbacks) {
|
|
98
|
+
if (!node.condition) {
|
|
99
|
+
throw new ExecutorError(node.id, 'Router node has no "condition" field');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const conditionStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
|
|
103
|
+
? node.condition
|
|
104
|
+
: `{{${node.condition}}}`;
|
|
105
|
+
|
|
106
|
+
const routeKey = resolveTemplate(conditionStr, context);
|
|
107
|
+
const stringKey = String(routeKey);
|
|
108
|
+
|
|
109
|
+
const routes = node.routes || {};
|
|
110
|
+
let nextNode = routes[stringKey];
|
|
111
|
+
|
|
112
|
+
if (!nextNode && node.default) {
|
|
113
|
+
nextNode = typeof node.default === 'string' && routes[node.default]
|
|
114
|
+
? routes[node.default]
|
|
115
|
+
: node.default;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!nextNode) {
|
|
119
|
+
throw new ExecutorError(
|
|
120
|
+
node.id,
|
|
121
|
+
`Router could not match "${stringKey}" to any route. Available: ${Object.keys(routes).join(', ')}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { next: nextNode, outputs: { routeKey: stringKey } };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sequence node: execute steps sequentially, collect step outputs.
|
|
130
|
+
*/
|
|
131
|
+
async function executeSequence(node, context, callbacks) {
|
|
132
|
+
const steps = node.steps || [];
|
|
133
|
+
|
|
134
|
+
for (const step of steps) {
|
|
135
|
+
const stepLabel = step.as || step.action || 'unnamed';
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
let stepOutput = null;
|
|
139
|
+
|
|
140
|
+
switch (step.action) {
|
|
141
|
+
case 'bash': {
|
|
142
|
+
const command = resolveTemplate(step.command, context);
|
|
143
|
+
if (callbacks.onBash) {
|
|
144
|
+
stepOutput = await callbacks.onBash(command);
|
|
145
|
+
} else {
|
|
146
|
+
stepOutput = { output: '', exitCode: -1, skipped: true };
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'read_files': {
|
|
152
|
+
const files = resolveTemplate(step.files, context);
|
|
153
|
+
if (callbacks.onReadFiles) {
|
|
154
|
+
stepOutput = await callbacks.onReadFiles(files);
|
|
155
|
+
} else {
|
|
156
|
+
stepOutput = { content: '', skipped: true };
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'agent': {
|
|
162
|
+
const resolvedInputs = step.inputs
|
|
163
|
+
? resolveTemplate(step.inputs, context)
|
|
164
|
+
: {};
|
|
165
|
+
if (callbacks.onAgent) {
|
|
166
|
+
stepOutput = await callbacks.onAgent({
|
|
167
|
+
agent: step.agent,
|
|
168
|
+
model: step.model,
|
|
169
|
+
inputs: resolvedInputs,
|
|
170
|
+
as: stepLabel
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
stepOutput = { result: null, skipped: true };
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
default: {
|
|
179
|
+
// Unrecognized step actions (load_memory, inject_state, learn_patterns, etc.)
|
|
180
|
+
// These are aspirational actions not yet implemented in the engine.
|
|
181
|
+
stepOutput = { skipped: true, reason: `Action "${step.action}" not implemented in engine` };
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Store step output in context
|
|
187
|
+
if (step.as) {
|
|
188
|
+
context.steps[step.as] = { output: stepOutput };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (step.optional) {
|
|
193
|
+
// Optional steps don't break the flow
|
|
194
|
+
if (step.as) {
|
|
195
|
+
context.steps[step.as] = { output: null, error: err.message };
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (node.errorPolicy === 'continue') {
|
|
201
|
+
if (step.as) {
|
|
202
|
+
context.steps[step.as] = { output: null, error: err.message };
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new ExecutorError(node.id, `Step "${stepLabel}" failed: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Resolve output templates
|
|
212
|
+
const resolvedOutputs = node.outputs
|
|
213
|
+
? resolveTemplate(node.outputs, context)
|
|
214
|
+
: {};
|
|
215
|
+
|
|
216
|
+
return { next: node.next, outputs: resolvedOutputs };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- Complex Executors (Wave 3) ----------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Agent node: invoke an external agent via callback.
|
|
223
|
+
*/
|
|
224
|
+
async function executeAgent(node, context, callbacks) {
|
|
225
|
+
const resolvedInputs = node.inputs
|
|
226
|
+
? resolveTemplate(node.inputs, context)
|
|
227
|
+
: {};
|
|
228
|
+
|
|
229
|
+
let result = null;
|
|
230
|
+
|
|
231
|
+
if (callbacks.onAgent) {
|
|
232
|
+
result = await callbacks.onAgent({
|
|
233
|
+
id: node.id,
|
|
234
|
+
agent: node.agent,
|
|
235
|
+
config: node.config || {},
|
|
236
|
+
inputs: resolvedInputs
|
|
237
|
+
});
|
|
238
|
+
} else {
|
|
239
|
+
result = { result: null, skipped: true };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Store in context
|
|
243
|
+
context.nodes[node.id] = context.nodes[node.id] || {};
|
|
244
|
+
context.nodes[node.id].outputs = { output: result };
|
|
245
|
+
|
|
246
|
+
// Resolve output templates (some agent nodes define outputs)
|
|
247
|
+
const resolvedOutputs = node.outputs
|
|
248
|
+
? resolveTemplate(node.outputs, context)
|
|
249
|
+
: { output: result };
|
|
250
|
+
|
|
251
|
+
return { next: node.next, outputs: resolvedOutputs };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Parallel node: execute branches concurrently via callback.
|
|
256
|
+
*/
|
|
257
|
+
async function executeParallel(node, context, callbacks) {
|
|
258
|
+
const branches = node.branches || [];
|
|
259
|
+
|
|
260
|
+
// Resolve inputs for each branch
|
|
261
|
+
const resolvedBranches = branches.map(branch => ({
|
|
262
|
+
...branch,
|
|
263
|
+
inputs: branch.inputs ? resolveTemplate(branch.inputs, context) : {}
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
// Agent Teams enforcement: if 2+ branches, notify callback
|
|
267
|
+
if (resolvedBranches.length >= 2 && callbacks.onTeamRequired) {
|
|
268
|
+
await callbacks.onTeamRequired({
|
|
269
|
+
nodeId: node.id,
|
|
270
|
+
branches: resolvedBranches
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Track iterations for maxIterations support
|
|
275
|
+
context._iterations = context._iterations || {};
|
|
276
|
+
context._iterations[node.id] = (context._iterations[node.id] || 0) + 1;
|
|
277
|
+
|
|
278
|
+
let branchResults;
|
|
279
|
+
|
|
280
|
+
if (callbacks.onParallel) {
|
|
281
|
+
branchResults = await callbacks.onParallel({
|
|
282
|
+
nodeId: node.id,
|
|
283
|
+
branches: resolvedBranches,
|
|
284
|
+
joinType: node.joinType || 'all',
|
|
285
|
+
timeout: node.timeout
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
branchResults = resolvedBranches.map(b => ({ id: b.id, result: null, skipped: true }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Store branch results
|
|
292
|
+
const branchOutputs = {};
|
|
293
|
+
if (Array.isArray(branchResults)) {
|
|
294
|
+
for (let i = 0; i < branchResults.length; i++) {
|
|
295
|
+
const branchId = resolvedBranches[i]?.id || `branch_${i}`;
|
|
296
|
+
branchOutputs[branchId] = branchResults[i];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check maxIterations
|
|
301
|
+
if (node.maxIterations && context._iterations[node.id] >= node.maxIterations) {
|
|
302
|
+
if (node.onMaxIterations) {
|
|
303
|
+
return { next: node.onMaxIterations, outputs: { branches: branchOutputs } };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { next: node.next, outputs: { branches: branchOutputs } };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Interrupt node: pause execution, display info, route on user choice.
|
|
312
|
+
*/
|
|
313
|
+
async function executeInterrupt(node, context, callbacks) {
|
|
314
|
+
const resolvedDisplay = node.display
|
|
315
|
+
? resolveTemplate(node.display, context)
|
|
316
|
+
: { title: node.reason || 'Interrupt', options: [] };
|
|
317
|
+
|
|
318
|
+
let selectedOption = null;
|
|
319
|
+
|
|
320
|
+
if (callbacks.onInterrupt) {
|
|
321
|
+
selectedOption = await callbacks.onInterrupt({
|
|
322
|
+
nodeId: node.id,
|
|
323
|
+
display: resolvedDisplay,
|
|
324
|
+
routes: node.routes || {},
|
|
325
|
+
reason: node.reason
|
|
326
|
+
});
|
|
327
|
+
} else {
|
|
328
|
+
// Default: pick first option if available
|
|
329
|
+
const options = resolvedDisplay.options || [];
|
|
330
|
+
selectedOption = options.length > 0 ? options[0].id : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const routes = node.routes || {};
|
|
334
|
+
const nextNode = selectedOption ? routes[selectedOption] : null;
|
|
335
|
+
|
|
336
|
+
if (!nextNode) {
|
|
337
|
+
throw new ExecutorError(
|
|
338
|
+
node.id,
|
|
339
|
+
`Interrupt: user selected "${selectedOption}" but no matching route found. Available: ${Object.keys(routes).join(', ')}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { next: nextNode, outputs: { selection: selectedOption } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Team node: deprecated, throws NotImplementedError.
|
|
348
|
+
*/
|
|
349
|
+
async function executeTeam(node, context, callbacks) {
|
|
350
|
+
// Team nodes are deprecated in ELSABRO v5.3+
|
|
351
|
+
// Agent Teams are now handled inline via IMPERATIVO_AGENT_TEAMS in execute.md
|
|
352
|
+
if (node.runtime_status === 'not_implemented') {
|
|
353
|
+
throw new NotImplementedError(node.id, node.gaps);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If somehow called on a non-deprecated team node
|
|
357
|
+
if (callbacks.onTeam) {
|
|
358
|
+
const result = await callbacks.onTeam(node);
|
|
359
|
+
return { next: node.next, outputs: result || {} };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { next: node.next, outputs: {} };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------- Executor registry ----------
|
|
366
|
+
|
|
367
|
+
const executors = {
|
|
368
|
+
entry: executeEntry,
|
|
369
|
+
exit: executeExit,
|
|
370
|
+
condition: executeCondition,
|
|
371
|
+
router: executeRouter,
|
|
372
|
+
sequence: executeSequence,
|
|
373
|
+
agent: executeAgent,
|
|
374
|
+
parallel: executeParallel,
|
|
375
|
+
interrupt: executeInterrupt,
|
|
376
|
+
team: executeTeam
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get the executor function for a node type.
|
|
381
|
+
* @param {string} type
|
|
382
|
+
* @returns {function}
|
|
383
|
+
*/
|
|
384
|
+
function getExecutor(type) {
|
|
385
|
+
const executor = executors[type];
|
|
386
|
+
if (!executor) {
|
|
387
|
+
throw new ExecutorError('unknown', `No executor for node type "${type}"`);
|
|
388
|
+
}
|
|
389
|
+
return executor;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = {
|
|
393
|
+
executeEntry,
|
|
394
|
+
executeExit,
|
|
395
|
+
executeCondition,
|
|
396
|
+
executeRouter,
|
|
397
|
+
executeSequence,
|
|
398
|
+
executeAgent,
|
|
399
|
+
executeParallel,
|
|
400
|
+
executeInterrupt,
|
|
401
|
+
executeTeam,
|
|
402
|
+
getExecutor,
|
|
403
|
+
checkRuntimeStatus,
|
|
404
|
+
NotImplementedError,
|
|
405
|
+
ExecutorError
|
|
406
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Graph builder and node lookup for ELSABRO flow definitions.
|
|
5
|
+
*
|
|
6
|
+
* Takes a raw flow JSON object (with a "nodes" array) and produces
|
|
7
|
+
* a Map-based graph that the engine traverses at runtime.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class GraphError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'GraphError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build an in-memory graph from a flow definition.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} flowDefinition – parsed development-flow.json
|
|
21
|
+
* @returns {{ nodes: Map<string, object>, entryNode: string, meta: object }}
|
|
22
|
+
*/
|
|
23
|
+
function buildGraph(flowDefinition) {
|
|
24
|
+
if (!flowDefinition || !Array.isArray(flowDefinition.nodes)) {
|
|
25
|
+
throw new GraphError('Flow definition must have a "nodes" array');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const nodes = new Map();
|
|
29
|
+
let entryNode = null;
|
|
30
|
+
|
|
31
|
+
for (const node of flowDefinition.nodes) {
|
|
32
|
+
if (!node.id) {
|
|
33
|
+
throw new GraphError('Every node must have an "id" field');
|
|
34
|
+
}
|
|
35
|
+
if (nodes.has(node.id)) {
|
|
36
|
+
throw new GraphError(`Duplicate node id: "${node.id}"`);
|
|
37
|
+
}
|
|
38
|
+
nodes.set(node.id, node);
|
|
39
|
+
|
|
40
|
+
if (node.type === 'entry') {
|
|
41
|
+
if (entryNode !== null) {
|
|
42
|
+
throw new GraphError(`Multiple entry nodes found: "${entryNode}" and "${node.id}"`);
|
|
43
|
+
}
|
|
44
|
+
entryNode = node.id;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (entryNode === null) {
|
|
49
|
+
throw new GraphError('No entry node (type "entry") found in flow');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const meta = {
|
|
53
|
+
id: flowDefinition.id || 'unknown',
|
|
54
|
+
name: flowDefinition.name || '',
|
|
55
|
+
version: flowDefinition.version || '0.0.0',
|
|
56
|
+
config: flowDefinition.config || {},
|
|
57
|
+
inputs: flowDefinition.inputs || {},
|
|
58
|
+
outputs: flowDefinition.outputs || {},
|
|
59
|
+
sync_metadata: flowDefinition.sync_metadata || null
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return { nodes, entryNode, meta };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate that all node references (next, routes, true, false, onMaxIterations,
|
|
67
|
+
* onError) point to existing nodes.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ nodes: Map<string, object> }} graph
|
|
70
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
71
|
+
*/
|
|
72
|
+
function validateGraph(graph) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
const nodeIds = new Set(graph.nodes.keys());
|
|
75
|
+
|
|
76
|
+
for (const [id, node] of graph.nodes) {
|
|
77
|
+
const refs = getNextNodes(node);
|
|
78
|
+
for (const ref of refs) {
|
|
79
|
+
if (!nodeIds.has(ref)) {
|
|
80
|
+
errors.push(`Node "${id}" references non-existent node "${ref}"`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { valid: errors.length === 0, errors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get a node by ID, or throw if not found.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ nodes: Map<string, object> }} graph
|
|
92
|
+
* @param {string} id
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
function getNode(graph, id) {
|
|
96
|
+
const node = graph.nodes.get(id);
|
|
97
|
+
if (!node) {
|
|
98
|
+
throw new GraphError(`Node "${id}" not found in graph`);
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Collect all possible next-node IDs from a node (for validation purposes).
|
|
105
|
+
* This covers: next, routes, true, false, onMaxIterations, onError.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} node
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
function getNextNodes(node) {
|
|
111
|
+
const refs = [];
|
|
112
|
+
|
|
113
|
+
if (node.next) refs.push(node.next);
|
|
114
|
+
if (node.true) refs.push(node.true);
|
|
115
|
+
if (node.false) refs.push(node.false);
|
|
116
|
+
if (node.onMaxIterations) refs.push(node.onMaxIterations);
|
|
117
|
+
if (node.onError) refs.push(node.onError);
|
|
118
|
+
|
|
119
|
+
if (node.routes && typeof node.routes === 'object') {
|
|
120
|
+
for (const target of Object.values(node.routes)) {
|
|
121
|
+
if (typeof target === 'string') refs.push(target);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Deduplicate
|
|
126
|
+
return [...new Set(refs)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { buildGraph, validateGraph, getNode, getNextNodes, GraphError };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FlowEngine — main entry point for the ELSABRO flow engine runtime.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { FlowEngine } = require('./flow-engine/src/index.js');
|
|
8
|
+
* const flow = require('./flows/development-flow.json');
|
|
9
|
+
* const engine = new FlowEngine({ callbacks: { onAgent, onBash, ... } });
|
|
10
|
+
* engine.loadFlow(flow);
|
|
11
|
+
* const result = await engine.run({ task: '...', profile: 'default' }, callbacks);
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { buildGraph, validateGraph, getNode, GraphError } = require('./graph');
|
|
15
|
+
const { resolveTemplate, resolveExpression, TemplateError } = require('./template');
|
|
16
|
+
const { runFlow, serializeContext, deserializeContext } = require('./runner');
|
|
17
|
+
const { CheckpointManager } = require('./checkpoint');
|
|
18
|
+
const { NotImplementedError } = require('./executors');
|
|
19
|
+
|
|
20
|
+
class FlowEngine {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {object} [options.callbacks] – default callbacks for all operations
|
|
24
|
+
* @param {object} [options.config] – engine-level config overrides
|
|
25
|
+
*/
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.callbacks = options.callbacks || {};
|
|
28
|
+
this.config = options.config || {};
|
|
29
|
+
this.graph = null;
|
|
30
|
+
this._flowDefinition = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load and validate a flow definition (e.g. development-flow.json).
|
|
35
|
+
*
|
|
36
|
+
* @param {object} flowJson – parsed flow JSON
|
|
37
|
+
* @returns {this}
|
|
38
|
+
*/
|
|
39
|
+
loadFlow(flowJson) {
|
|
40
|
+
this._flowDefinition = flowJson;
|
|
41
|
+
this.graph = buildGraph(flowJson);
|
|
42
|
+
|
|
43
|
+
const validation = validateGraph(this.graph);
|
|
44
|
+
if (!validation.valid) {
|
|
45
|
+
throw new GraphError(
|
|
46
|
+
`Flow graph has ${validation.errors.length} reference error(s):\n` +
|
|
47
|
+
validation.errors.join('\n')
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get a node by ID.
|
|
56
|
+
* @param {string} id
|
|
57
|
+
* @returns {object}
|
|
58
|
+
*/
|
|
59
|
+
getNode(id) {
|
|
60
|
+
if (!this.graph) {
|
|
61
|
+
throw new GraphError('No flow loaded. Call loadFlow() first.');
|
|
62
|
+
}
|
|
63
|
+
return getNode(this.graph, id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get flow-level metadata (version, config, sync_metadata).
|
|
68
|
+
* @returns {object}
|
|
69
|
+
*/
|
|
70
|
+
getFlowMetadata() {
|
|
71
|
+
if (!this.graph) {
|
|
72
|
+
throw new GraphError('No flow loaded. Call loadFlow() first.');
|
|
73
|
+
}
|
|
74
|
+
return this.graph.meta;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get total node count.
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
getNodeCount() {
|
|
82
|
+
if (!this.graph) return 0;
|
|
83
|
+
return this.graph.nodes.size;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get nodes filtered by a predicate.
|
|
88
|
+
* @param {function} predicate – (node) => boolean
|
|
89
|
+
* @returns {object[]}
|
|
90
|
+
*/
|
|
91
|
+
getNodesWhere(predicate) {
|
|
92
|
+
if (!this.graph) return [];
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const node of this.graph.nodes.values()) {
|
|
95
|
+
if (predicate(node)) results.push(node);
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a template expression against a context.
|
|
102
|
+
* Convenience method wrapping template.js.
|
|
103
|
+
*
|
|
104
|
+
* @param {*} template
|
|
105
|
+
* @param {object} context
|
|
106
|
+
* @returns {*}
|
|
107
|
+
*/
|
|
108
|
+
resolveTemplate(template, context) {
|
|
109
|
+
return resolveTemplate(template, context);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run the loaded flow from the entry node.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} inputs – flow inputs (task, profile, complexity, etc.)
|
|
116
|
+
* @param {object} [callbacks] – override callbacks for this run
|
|
117
|
+
* @returns {Promise<{ success: boolean, outputs: object, context: object, nodesVisited: string[] }>}
|
|
118
|
+
*/
|
|
119
|
+
async run(inputs, callbacks) {
|
|
120
|
+
const mergedCallbacks = { ...this.callbacks, ...callbacks };
|
|
121
|
+
return runFlow(this, inputs, mergedCallbacks);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resume a flow from a checkpoint.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} checkpoint – checkpoint data with { nextNode, context, nodesVisited }
|
|
128
|
+
* @param {object} [callbacks] – override callbacks for this run
|
|
129
|
+
* @returns {Promise<{ success: boolean, outputs: object, context: object, nodesVisited: string[] }>}
|
|
130
|
+
*/
|
|
131
|
+
async resume(checkpoint, callbacks) {
|
|
132
|
+
const mergedCallbacks = { ...this.callbacks, ...callbacks };
|
|
133
|
+
return runFlow(this, null, mergedCallbacks, checkpoint);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { FlowEngine, GraphError, TemplateError, NotImplementedError, CheckpointManager };
|