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.
@@ -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 };