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,184 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Main execution loop for the ELSABRO flow engine.
5
+ *
6
+ * Traverses the node graph sequentially, dispatching to type-specific
7
+ * executors, saving checkpoints after each node, and handling
8
+ * not_implemented nodes gracefully.
9
+ */
10
+
11
+ const { getExecutor, checkRuntimeStatus, NotImplementedError } = require('./executors');
12
+
13
+ class RunnerError extends Error {
14
+ constructor(message, nodeId) {
15
+ super(message);
16
+ this.name = 'RunnerError';
17
+ this.nodeId = nodeId || null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Run a flow from the entry node (or from a checkpoint).
23
+ *
24
+ * @param {object} engine – FlowEngine instance (has .graph)
25
+ * @param {object} inputs – flow inputs (task, profile, complexity, etc.)
26
+ * @param {object} callbacks – all external action callbacks
27
+ * @param {object} [checkpoint] – optional checkpoint to resume from
28
+ * @returns {Promise<{ success: boolean, outputs: object, context: object, nodesVisited: string[] }>}
29
+ */
30
+ async function runFlow(engine, inputs, callbacks, checkpoint) {
31
+ const graph = engine.graph;
32
+ if (!graph) {
33
+ throw new RunnerError('No flow loaded. Call engine.loadFlow() first.');
34
+ }
35
+
36
+ // Build or restore context
37
+ const context = checkpoint
38
+ ? deserializeContext(checkpoint.context)
39
+ : {
40
+ inputs: inputs || {},
41
+ nodes: {},
42
+ steps: {},
43
+ state: {},
44
+ _iterations: {}
45
+ };
46
+
47
+ // If not resuming, set inputs
48
+ if (!checkpoint) {
49
+ context.inputs = inputs || {};
50
+ }
51
+
52
+ let currentNodeId = checkpoint ? checkpoint.nextNode : graph.entryNode;
53
+ const nodesVisited = checkpoint ? (checkpoint.nodesVisited || []) : [];
54
+ const maxNodes = 200; // Safety: prevent infinite loops
55
+ let nodeCount = 0;
56
+
57
+ while (currentNodeId !== null) {
58
+ nodeCount++;
59
+ if (nodeCount > maxNodes) {
60
+ throw new RunnerError(
61
+ `Flow exceeded maximum node traversals (${maxNodes}). Possible infinite loop.`,
62
+ currentNodeId
63
+ );
64
+ }
65
+
66
+ const node = graph.nodes.get(currentNodeId);
67
+ if (!node) {
68
+ throw new RunnerError(`Node "${currentNodeId}" not found in graph`, currentNodeId);
69
+ }
70
+
71
+ // 1. Notify: node starting
72
+ if (callbacks.onNodeStart) {
73
+ await callbacks.onNodeStart(node.id, node.type);
74
+ }
75
+
76
+ // 2. Check runtime_status + Execute node
77
+ const executor = getExecutor(node.type);
78
+ let result;
79
+
80
+ try {
81
+ checkRuntimeStatus(node);
82
+ result = await executor(node, context, callbacks);
83
+ } catch (err) {
84
+ // Let NotImplementedError propagate cleanly
85
+ if (err instanceof NotImplementedError) {
86
+ // Save a checkpoint before stopping
87
+ if (callbacks.onCheckpoint) {
88
+ await callbacks.onCheckpoint({
89
+ currentNode: node.id,
90
+ nextNode: null,
91
+ context: serializeContext(context),
92
+ nodesVisited,
93
+ stoppedAt: node.id,
94
+ reason: err.message
95
+ });
96
+ }
97
+ throw err;
98
+ }
99
+
100
+ // Other errors: try onError route if the node has one
101
+ if (node.onError) {
102
+ result = { next: node.onError, outputs: { error: err.message } };
103
+ } else {
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ // 4. Store outputs
109
+ context.nodes[node.id] = context.nodes[node.id] || {};
110
+ context.nodes[node.id].outputs = result.outputs || {};
111
+
112
+ nodesVisited.push(node.id);
113
+
114
+ // 5. Checkpoint
115
+ if (callbacks.onCheckpoint) {
116
+ await callbacks.onCheckpoint({
117
+ currentNode: node.id,
118
+ nextNode: result.next,
119
+ context: serializeContext(context),
120
+ nodesVisited: [...nodesVisited]
121
+ });
122
+ }
123
+
124
+ // 6. Notify: node completed
125
+ if (callbacks.onNodeComplete) {
126
+ await callbacks.onNodeComplete(node.id, result);
127
+ }
128
+
129
+ // 7. Advance
130
+ currentNodeId = result.next;
131
+ }
132
+
133
+ // Determine success based on which exit node was reached
134
+ const lastNodeId = nodesVisited[nodesVisited.length - 1];
135
+ const lastNode = graph.nodes.get(lastNodeId);
136
+ const isSuccess = lastNode && lastNode.status === 'success';
137
+
138
+ return {
139
+ success: isSuccess,
140
+ outputs: context.nodes[lastNodeId]?.outputs || {},
141
+ context,
142
+ nodesVisited
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Create a JSON-serializable copy of context (strips functions, circular refs).
148
+ *
149
+ * @param {object} context
150
+ * @returns {object}
151
+ */
152
+ function serializeContext(context) {
153
+ const safe = {};
154
+
155
+ for (const key of ['inputs', 'nodes', 'steps', 'state', '_iterations']) {
156
+ if (context[key] !== undefined) {
157
+ try {
158
+ safe[key] = JSON.parse(JSON.stringify(context[key]));
159
+ } catch {
160
+ safe[key] = null;
161
+ }
162
+ }
163
+ }
164
+
165
+ return safe;
166
+ }
167
+
168
+ /**
169
+ * Reconstruct a context from a checkpoint.
170
+ *
171
+ * @param {object} data
172
+ * @returns {object}
173
+ */
174
+ function deserializeContext(data) {
175
+ return {
176
+ inputs: data.inputs || {},
177
+ nodes: data.nodes || {},
178
+ steps: data.steps || {},
179
+ state: data.state || {},
180
+ _iterations: data._iterations || {}
181
+ };
182
+ }
183
+
184
+ module.exports = { runFlow, serializeContext, deserializeContext, RunnerError };
@@ -0,0 +1,290 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Template expression resolver for ELSABRO flow engine.
5
+ *
6
+ * Handles 7 expression types:
7
+ * 1. Simple interpolation: {{inputs.task}}
8
+ * 2. Deep path access: {{nodes.X.outputs.Y.Z}}
9
+ * 3. Fallback operator: {{expr || defaultValue}}
10
+ * 4. Boolean expressions: {{a === 0 && b === 0}}
11
+ * 5. Function calls: {{functionName(args)}}
12
+ * 6. Filter syntax: {{value | filterName}}
13
+ * 7. Object/array recursion
14
+ */
15
+
16
+ class TemplateError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'TemplateError';
20
+ }
21
+ }
22
+
23
+ // Registry for built-in functions and filters
24
+ const _functions = new Map();
25
+ const _filters = new Map();
26
+
27
+ /**
28
+ * Register a built-in function that can be called from templates.
29
+ * @param {string} name
30
+ * @param {function} fn
31
+ */
32
+ function registerFunction(name, fn) {
33
+ _functions.set(name, fn);
34
+ }
35
+
36
+ /**
37
+ * Register a filter that can be piped to.
38
+ * @param {string} name
39
+ * @param {function} fn
40
+ */
41
+ function registerFilter(name, fn) {
42
+ _filters.set(name, fn);
43
+ }
44
+
45
+ // ---------- Built-in functions ----------
46
+
47
+ registerFunction('collectOutputs', (context, key) => {
48
+ const results = [];
49
+ if (context.nodes) {
50
+ for (const nodeData of Object.values(context.nodes)) {
51
+ const outputs = nodeData.outputs || nodeData;
52
+ if (outputs && outputs[key] !== undefined) {
53
+ const val = outputs[key];
54
+ if (Array.isArray(val)) {
55
+ results.push(...val);
56
+ } else {
57
+ results.push(val);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ return results;
63
+ });
64
+
65
+ registerFunction('collectDecisions', (context) => {
66
+ return (context.state && context.state.decisions) || [];
67
+ });
68
+
69
+ registerFunction('collectErrors', (context) => {
70
+ return (context.state && context.state.errors) || [];
71
+ });
72
+
73
+ registerFunction('hasCriticalIssues', (context, obj) => {
74
+ if (!obj) return false;
75
+ const json = typeof obj === 'string' ? obj : JSON.stringify(obj);
76
+ return json.includes('"critical"') || json.includes('"blocking"');
77
+ });
78
+
79
+ registerFunction('generateSummary', (context) => {
80
+ const state = context.state || {};
81
+ const nodeCount = context.nodes ? Object.keys(context.nodes).length : 0;
82
+ return `Flow completed. ${nodeCount} nodes executed. Status: ${state.status || 'unknown'}`;
83
+ });
84
+
85
+ // ---------- Built-in filters ----------
86
+
87
+ registerFilter('slugify', (value) => {
88
+ if (typeof value !== 'string') return String(value);
89
+ return value
90
+ .toLowerCase()
91
+ .trim()
92
+ .replace(/[^\w\s-]/g, '')
93
+ .replace(/[\s_]+/g, '-')
94
+ .replace(/-+/g, '-');
95
+ });
96
+
97
+ // ---------- Path resolution ----------
98
+
99
+ /**
100
+ * Walk a dotted path on an object. Returns undefined if any segment is missing.
101
+ * @param {object} obj
102
+ * @param {string} pathStr – e.g. "nodes.skill_discovery.outputs.discoveryResult"
103
+ * @returns {*}
104
+ */
105
+ function resolvePath(obj, pathStr) {
106
+ const parts = pathStr.split('.');
107
+ let current = obj;
108
+ for (const part of parts) {
109
+ if (current == null || typeof current !== 'object') return undefined;
110
+ current = current[part];
111
+ }
112
+ return current;
113
+ }
114
+
115
+ // ---------- Expression parsing ----------
116
+
117
+ /**
118
+ * Try to parse a literal value (number, boolean, string, array).
119
+ * Returns { value, matched } or { matched: false }.
120
+ */
121
+ function parseLiteral(str) {
122
+ const trimmed = str.trim();
123
+
124
+ // Boolean
125
+ if (trimmed === 'true') return { value: true, matched: true };
126
+ if (trimmed === 'false') return { value: false, matched: true };
127
+
128
+ // Number
129
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
130
+ return { value: Number(trimmed), matched: true };
131
+ }
132
+
133
+ // Quoted string (single or double)
134
+ const strMatch = trimmed.match(/^(['"])(.*)\1$/);
135
+ if (strMatch) {
136
+ return { value: strMatch[2], matched: true };
137
+ }
138
+
139
+ // Empty array
140
+ if (trimmed === '[]') return { value: [], matched: true };
141
+ if (trimmed === '{}') return { value: {}, matched: true };
142
+
143
+ return { matched: false };
144
+ }
145
+
146
+ /**
147
+ * Resolve a single expression string (the content inside {{ }}).
148
+ *
149
+ * @param {string} expr – raw expression without {{ }}
150
+ * @param {object} context – { inputs, nodes, steps, state }
151
+ * @returns {*}
152
+ */
153
+ function resolveExpression(expr, context) {
154
+ const trimmed = expr.trim();
155
+
156
+ // 1. Check for filter syntax: "value | filterName"
157
+ // Only match pipes that are NOT inside || (fallback operator)
158
+ const filterMatch = trimmed.match(/^(.+?)\s*\|\s*(?!\|)(\w+)\s*$/);
159
+ if (filterMatch) {
160
+ const innerValue = resolveExpression(filterMatch[1], context);
161
+ const filterName = filterMatch[2];
162
+ const filter = _filters.get(filterName);
163
+ if (!filter) {
164
+ throw new TemplateError(`Unknown filter: "${filterName}"`);
165
+ }
166
+ return filter(innerValue);
167
+ }
168
+
169
+ // 2. Check for fallback operator: "expr || default"
170
+ const fallbackMatch = trimmed.match(/^(.+?)\s*\|\|\s*(.+)$/);
171
+ if (fallbackMatch) {
172
+ const primary = resolveExpression(fallbackMatch[1], context);
173
+ if (primary != null && primary !== '') return primary;
174
+ // Try to resolve the default as an expression or literal
175
+ const fallbackLiteral = parseLiteral(fallbackMatch[2]);
176
+ if (fallbackLiteral.matched) return fallbackLiteral.value;
177
+ return resolveExpression(fallbackMatch[2], context);
178
+ }
179
+
180
+ // 3. Check for negation: "!expr"
181
+ if (trimmed.startsWith('!')) {
182
+ const inner = resolveExpression(trimmed.slice(1), context);
183
+ return !inner;
184
+ }
185
+
186
+ // 4. Check for boolean && expression
187
+ if (trimmed.includes('&&')) {
188
+ const parts = trimmed.split('&&').map(p => p.trim());
189
+ return parts.every(part => {
190
+ const val = resolveExpression(part, context);
191
+ return !!val;
192
+ });
193
+ }
194
+
195
+ // 5. Check for comparison operators: ===, !==
196
+ const compMatch = trimmed.match(/^(.+?)\s*(===|!==)\s*(.+)$/);
197
+ if (compMatch) {
198
+ const left = resolveExpression(compMatch[1], context);
199
+ const right = resolveExpression(compMatch[3], context);
200
+ if (compMatch[2] === '===') return left === right;
201
+ if (compMatch[2] === '!==') return left !== right;
202
+ }
203
+
204
+ // 6. Check for function call: "functionName(args)"
205
+ const funcMatch = trimmed.match(/^(\w+)\(([^)]*)\)$/);
206
+ if (funcMatch) {
207
+ const funcName = funcMatch[1];
208
+ const fn = _functions.get(funcName);
209
+ if (!fn) {
210
+ throw new TemplateError(`Unknown function: "${funcName}"`);
211
+ }
212
+ const argsStr = funcMatch[2].trim();
213
+ if (argsStr === '') {
214
+ return fn(context);
215
+ }
216
+ // Parse argument: could be a path reference or a quoted string
217
+ const argLiteral = parseLiteral(argsStr);
218
+ if (argLiteral.matched) {
219
+ return fn(context, argLiteral.value);
220
+ }
221
+ // Resolve as expression (path)
222
+ const argValue = resolveExpression(argsStr, context);
223
+ return fn(context, argValue);
224
+ }
225
+
226
+ // 7. Literal value
227
+ const literal = parseLiteral(trimmed);
228
+ if (literal.matched) return literal.value;
229
+
230
+ // 8. Path resolution (the most common case)
231
+ const value = resolvePath(context, trimmed);
232
+ return value;
233
+ }
234
+
235
+ /**
236
+ * Resolve all {{expression}} patterns in a template.
237
+ *
238
+ * If the template is a string, replace all {{ }} patterns.
239
+ * If the template is an object or array, recursively resolve all string values.
240
+ * If the template is not a string/object/array, return as-is.
241
+ *
242
+ * @param {*} template
243
+ * @param {object} context
244
+ * @returns {*}
245
+ */
246
+ function resolveTemplate(template, context) {
247
+ if (template == null) return template;
248
+
249
+ if (typeof template === 'string') {
250
+ // Check if the ENTIRE string is a single expression (return raw value, not stringified)
251
+ const fullMatch = template.match(/^\{\{(.+)\}\}$/s);
252
+ if (fullMatch && !template.includes('}}{{')) {
253
+ return resolveExpression(fullMatch[1], context);
254
+ }
255
+
256
+ // Otherwise, interpolate all {{ }} patterns as strings
257
+ return template.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
258
+ const value = resolveExpression(expr, context);
259
+ if (value === undefined || value === null) return '';
260
+ if (typeof value === 'object') return JSON.stringify(value);
261
+ return String(value);
262
+ });
263
+ }
264
+
265
+ if (Array.isArray(template)) {
266
+ return template.map(item => resolveTemplate(item, context));
267
+ }
268
+
269
+ if (typeof template === 'object') {
270
+ const result = {};
271
+ for (const [key, value] of Object.entries(template)) {
272
+ result[key] = resolveTemplate(value, context);
273
+ }
274
+ return result;
275
+ }
276
+
277
+ return template;
278
+ }
279
+
280
+ module.exports = {
281
+ resolveTemplate,
282
+ resolveExpression,
283
+ registerFunction,
284
+ registerFilter,
285
+ resolvePath,
286
+ TemplateError,
287
+ // Expose registries for testing
288
+ _functions,
289
+ _filters
290
+ };