elsabro 7.2.0 → 7.3.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/README.md +34 -0
- package/agents/elsabro-orchestrator.md +2 -0
- package/commands/elsabro/execute.md +346 -1471
- package/commands/elsabro/quick.md +11 -11
- package/commands/elsabro/start.md +156 -14
- package/flow-engine/src/callbacks.js +79 -0
- package/flow-engine/src/checkpoint.js +41 -0
- package/flow-engine/src/cli.js +214 -6
- package/flow-engine/src/executors.js +135 -4
- package/flow-engine/src/graph.js +30 -2
- package/flow-engine/src/template.js +152 -72
- package/flow-engine/tests/checkpoint.test.js +476 -0
- package/flow-engine/tests/execute-dispatcher.test.js +738 -0
- package/flow-engine/tests/executors-complex.test.js +259 -0
- package/flow-engine/tests/graph.test.js +193 -0
- package/flow-engine/tests/skill-install.test.js +254 -0
- package/flow-engine/tests/validation.test.js +137 -0
- package/flows/development-flow.json +23 -23
- package/hooks/skill-discovery.sh +0 -0
- package/package.json +1 -1
- package/references/SYSTEM_INDEX.md +1 -1
- package/references/agent-teams-integration.md +7 -15
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
* 9 node types total:
|
|
10
10
|
* Simple (Wave 2): entry, exit, condition, router, sequence
|
|
11
11
|
* Complex (Wave 3): agent, parallel, interrupt, team
|
|
12
|
+
*
|
|
13
|
+
* NOTE: The elsabro-orchestrator agent uses Agent Teams (TeamCreate, TeamDelete, SendMessage)
|
|
14
|
+
* for parallel execution coordination. This is NOT related to the elsabro-scrum-master agent,
|
|
15
|
+
* which is a separate utility for sprint planning. They are independent agents serving
|
|
16
|
+
* different purposes.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
const { resolveTemplate, resolveExpression } = require('./template');
|
|
20
|
+
const { serializeContext } = require('./runner');
|
|
15
21
|
|
|
16
22
|
// ---------- Error types ----------
|
|
17
23
|
|
|
@@ -59,6 +65,7 @@ function checkRuntimeStatus(node) {
|
|
|
59
65
|
* Entry node: no-op, just transitions to next.
|
|
60
66
|
*/
|
|
61
67
|
async function executeEntry(node, context, callbacks) {
|
|
68
|
+
checkRuntimeStatus(node);
|
|
62
69
|
return { next: node.next, outputs: {} };
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -66,6 +73,7 @@ async function executeEntry(node, context, callbacks) {
|
|
|
66
73
|
* Exit node: resolve output templates, terminate flow.
|
|
67
74
|
*/
|
|
68
75
|
async function executeExit(node, context, callbacks) {
|
|
76
|
+
checkRuntimeStatus(node);
|
|
69
77
|
const resolvedOutputs = node.outputs
|
|
70
78
|
? resolveTemplate(node.outputs, context)
|
|
71
79
|
: {};
|
|
@@ -81,7 +89,18 @@ async function executeExit(node, context, callbacks) {
|
|
|
81
89
|
* Condition node: evaluate condition, route to true/false branch.
|
|
82
90
|
*/
|
|
83
91
|
async function executeCondition(node, context, callbacks) {
|
|
92
|
+
checkRuntimeStatus(node);
|
|
84
93
|
if (!node.condition) {
|
|
94
|
+
// Save checkpoint before error
|
|
95
|
+
if (callbacks.onCheckpoint) {
|
|
96
|
+
await callbacks.onCheckpoint({
|
|
97
|
+
currentNode: node.id,
|
|
98
|
+
nextNode: null,
|
|
99
|
+
context: serializeContext(context),
|
|
100
|
+
stoppedAt: node.id,
|
|
101
|
+
reason: 'Condition node has no "condition" field'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
85
104
|
throw new ExecutorError(node.id, 'Condition node has no "condition" field');
|
|
86
105
|
}
|
|
87
106
|
|
|
@@ -94,6 +113,16 @@ async function executeCondition(node, context, callbacks) {
|
|
|
94
113
|
const nextNode = result ? node.true : node.false;
|
|
95
114
|
|
|
96
115
|
if (!nextNode) {
|
|
116
|
+
// Save checkpoint before error
|
|
117
|
+
if (callbacks.onCheckpoint) {
|
|
118
|
+
await callbacks.onCheckpoint({
|
|
119
|
+
currentNode: node.id,
|
|
120
|
+
nextNode: null,
|
|
121
|
+
context: serializeContext(context),
|
|
122
|
+
stoppedAt: node.id,
|
|
123
|
+
reason: `Condition evaluated to ${!!result} but no "${result ? 'true' : 'false'}" branch defined`
|
|
124
|
+
});
|
|
125
|
+
}
|
|
97
126
|
throw new ExecutorError(
|
|
98
127
|
node.id,
|
|
99
128
|
`Condition evaluated to ${!!result} but no "${result ? 'true' : 'false'}" branch defined`
|
|
@@ -107,6 +136,7 @@ async function executeCondition(node, context, callbacks) {
|
|
|
107
136
|
* Router node: evaluate condition as a string, match against routes map.
|
|
108
137
|
*/
|
|
109
138
|
async function executeRouter(node, context, callbacks) {
|
|
139
|
+
checkRuntimeStatus(node);
|
|
110
140
|
if (!node.condition) {
|
|
111
141
|
throw new ExecutorError(node.id, 'Router node has no "condition" field');
|
|
112
142
|
}
|
|
@@ -139,8 +169,19 @@ async function executeRouter(node, context, callbacks) {
|
|
|
139
169
|
|
|
140
170
|
/**
|
|
141
171
|
* Sequence node: execute steps sequentially, collect step outputs.
|
|
172
|
+
*
|
|
173
|
+
* Special integrations:
|
|
174
|
+
* - skill-discovery.sh: Auto-parses JSON output, extracts discovered/recommended skills
|
|
175
|
+
* - skill-install.sh: Can be called via bash action for skill installation
|
|
176
|
+
*
|
|
177
|
+
* Callbacks used:
|
|
178
|
+
* - onBash(command): Execute bash commands
|
|
179
|
+
* - onReadFiles(files): Read file contents
|
|
180
|
+
* - onAgent(params): Invoke agent execution
|
|
181
|
+
* - onLog(level, message): Optional logging callback for skill discovery results
|
|
142
182
|
*/
|
|
143
183
|
async function executeSequence(node, context, callbacks) {
|
|
184
|
+
checkRuntimeStatus(node);
|
|
144
185
|
const steps = node.steps || [];
|
|
145
186
|
|
|
146
187
|
for (const step of steps) {
|
|
@@ -154,6 +195,39 @@ async function executeSequence(node, context, callbacks) {
|
|
|
154
195
|
const command = resolveTemplate(step.command, context);
|
|
155
196
|
if (callbacks.onBash) {
|
|
156
197
|
stepOutput = await callbacks.onBash(command);
|
|
198
|
+
|
|
199
|
+
// Special handling for skill-discovery hook integration
|
|
200
|
+
if (command.includes('skill-discovery.sh') && stepOutput.output) {
|
|
201
|
+
try {
|
|
202
|
+
// Parse JSON output from skill-discovery
|
|
203
|
+
const discoveryData = JSON.parse(stepOutput.output);
|
|
204
|
+
|
|
205
|
+
// Enhance output with parsed discovery data
|
|
206
|
+
stepOutput.discovered = discoveryData.discovered || [];
|
|
207
|
+
stepOutput.recommended = discoveryData.recommended || {};
|
|
208
|
+
stepOutput.registry_available = discoveryData.registry_available || false;
|
|
209
|
+
|
|
210
|
+
// Log skill discovery results (if callbacks provide logging)
|
|
211
|
+
if (callbacks.onLog) {
|
|
212
|
+
callbacks.onLog('info', `[skill-discovery] Found ${stepOutput.discovered.length} installed skills`);
|
|
213
|
+
|
|
214
|
+
if (stepOutput.recommended.skills && stepOutput.recommended.skills.length > 0) {
|
|
215
|
+
callbacks.onLog('info', `[skill-discovery] Recommended: ${stepOutput.recommended.skills.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (stepOutput.recommended.install_commands && stepOutput.recommended.install_commands.length > 0) {
|
|
219
|
+
callbacks.onLog('info', `[skill-discovery] ${stepOutput.recommended.install_commands.length} install commands available`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (parseErr) {
|
|
223
|
+
// If JSON parse fails, log warning but don't fail the step
|
|
224
|
+
if (callbacks.onLog) {
|
|
225
|
+
callbacks.onLog('warn', `[skill-discovery] Failed to parse output as JSON: ${parseErr.message}`);
|
|
226
|
+
}
|
|
227
|
+
// Keep raw output available
|
|
228
|
+
stepOutput.parseError = parseErr.message;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
157
231
|
} else {
|
|
158
232
|
stepOutput = { output: '', exitCode: -1, skipped: true };
|
|
159
233
|
}
|
|
@@ -234,6 +308,7 @@ async function executeSequence(node, context, callbacks) {
|
|
|
234
308
|
* Agent node: invoke an external agent via callback.
|
|
235
309
|
*/
|
|
236
310
|
async function executeAgent(node, context, callbacks) {
|
|
311
|
+
checkRuntimeStatus(node);
|
|
237
312
|
const resolvedInputs = node.inputs
|
|
238
313
|
? resolveTemplate(node.inputs, context)
|
|
239
314
|
: {};
|
|
@@ -267,6 +342,7 @@ async function executeAgent(node, context, callbacks) {
|
|
|
267
342
|
* Parallel node: execute branches concurrently via callback.
|
|
268
343
|
*/
|
|
269
344
|
async function executeParallel(node, context, callbacks) {
|
|
345
|
+
checkRuntimeStatus(node);
|
|
270
346
|
const branches = node.branches || [];
|
|
271
347
|
|
|
272
348
|
// Resolve inputs for each branch
|
|
@@ -313,9 +389,65 @@ async function executeParallel(node, context, callbacks) {
|
|
|
313
389
|
if (node.maxIterations && context._iterations[node.id] >= node.maxIterations) {
|
|
314
390
|
if (node.onMaxIterations) {
|
|
315
391
|
return { next: node.onMaxIterations, outputs: { branches: branchOutputs } };
|
|
392
|
+
} else {
|
|
393
|
+
// Fall through to next when no handler defined (graceful degradation)
|
|
394
|
+
return { next: node.next, outputs: { branches: branchOutputs } };
|
|
316
395
|
}
|
|
317
396
|
}
|
|
318
397
|
|
|
398
|
+
// Check errorPolicy quorum for parallel failures
|
|
399
|
+
if (node.errorPolicy === 'quorum') {
|
|
400
|
+
// Count successful branches
|
|
401
|
+
let successCount = 0;
|
|
402
|
+
const totalCount = branchResults.length;
|
|
403
|
+
|
|
404
|
+
for (const branchResult of branchResults) {
|
|
405
|
+
// Consider a branch successful if it doesn't have an error and wasn't skipped
|
|
406
|
+
if (branchResult && !branchResult.error && !branchResult.skipped) {
|
|
407
|
+
successCount++;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Determine if quorum is met
|
|
412
|
+
let quorumMet = true;
|
|
413
|
+
let quorumType = 'default';
|
|
414
|
+
|
|
415
|
+
if (node.quorumThreshold !== undefined) {
|
|
416
|
+
// Percentage-based quorum (0.0 to 1.0)
|
|
417
|
+
quorumType = 'threshold';
|
|
418
|
+
const successRate = totalCount > 0 ? successCount / totalCount : 0;
|
|
419
|
+
quorumMet = successRate >= node.quorumThreshold;
|
|
420
|
+
} else if (node.quorumMinSuccess !== undefined) {
|
|
421
|
+
// Absolute count quorum
|
|
422
|
+
quorumType = 'minSuccess';
|
|
423
|
+
quorumMet = successCount >= node.quorumMinSuccess;
|
|
424
|
+
} else {
|
|
425
|
+
// Default: all must succeed (100% quorum)
|
|
426
|
+
quorumType = 'all';
|
|
427
|
+
quorumMet = successCount === totalCount;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Build detailed outputs
|
|
431
|
+
const quorumOutputs = {
|
|
432
|
+
branches: branchOutputs,
|
|
433
|
+
quorumMet,
|
|
434
|
+
quorumType,
|
|
435
|
+
successCount,
|
|
436
|
+
totalCount,
|
|
437
|
+
failureCount: totalCount - successCount,
|
|
438
|
+
successRate: totalCount > 0 ? successCount / totalCount : 0
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (!quorumMet) {
|
|
442
|
+
// Quorum not met, route to error handler if defined
|
|
443
|
+
const errorRoute = node.onError || node.next;
|
|
444
|
+
return { next: errorRoute, outputs: quorumOutputs };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Quorum met, proceed to next
|
|
448
|
+
return { next: node.next, outputs: quorumOutputs };
|
|
449
|
+
}
|
|
450
|
+
|
|
319
451
|
return { next: node.next, outputs: { branches: branchOutputs } };
|
|
320
452
|
}
|
|
321
453
|
|
|
@@ -323,6 +455,7 @@ async function executeParallel(node, context, callbacks) {
|
|
|
323
455
|
* Interrupt node: pause execution, display info, route on user choice.
|
|
324
456
|
*/
|
|
325
457
|
async function executeInterrupt(node, context, callbacks) {
|
|
458
|
+
checkRuntimeStatus(node);
|
|
326
459
|
const resolvedDisplay = node.display
|
|
327
460
|
? resolveTemplate(node.display, context)
|
|
328
461
|
: { title: node.reason || 'Interrupt', options: [] };
|
|
@@ -359,11 +492,9 @@ async function executeInterrupt(node, context, callbacks) {
|
|
|
359
492
|
* Team node: deprecated, throws NotImplementedError.
|
|
360
493
|
*/
|
|
361
494
|
async function executeTeam(node, context, callbacks) {
|
|
495
|
+
checkRuntimeStatus(node);
|
|
362
496
|
// Team nodes are deprecated in ELSABRO v5.3+
|
|
363
|
-
// Agent Teams are now handled
|
|
364
|
-
if (node.runtime_status === 'not_implemented') {
|
|
365
|
-
throw new NotImplementedError(node.id, node.gaps);
|
|
366
|
-
}
|
|
497
|
+
// Agent Teams are now handled via parallel dispatch in execute.md#3-dispatch + callbacks.js
|
|
367
498
|
|
|
368
499
|
// If somehow called on a non-deprecated team node
|
|
369
500
|
if (callbacks.onTeam) {
|
package/flow-engine/src/graph.js
CHANGED
|
@@ -64,15 +64,16 @@ function buildGraph(flowDefinition) {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Validate that all node references (next, routes, true, false, onMaxIterations,
|
|
67
|
-
* onError) point to existing nodes.
|
|
67
|
+
* onError) point to existing nodes, and detect orphaned nodes.
|
|
68
68
|
*
|
|
69
|
-
* @param {{ nodes: Map<string, object
|
|
69
|
+
* @param {{ nodes: Map<string, object>, entryNode: string }} graph
|
|
70
70
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
71
71
|
*/
|
|
72
72
|
function validateGraph(graph) {
|
|
73
73
|
const errors = [];
|
|
74
74
|
const nodeIds = new Set(graph.nodes.keys());
|
|
75
75
|
|
|
76
|
+
// 1. Check for dangling references (invalid routes)
|
|
76
77
|
for (const [id, node] of graph.nodes) {
|
|
77
78
|
const refs = getNextNodes(node);
|
|
78
79
|
for (const ref of refs) {
|
|
@@ -82,6 +83,33 @@ function validateGraph(graph) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// 2. Detect orphaned nodes (nodes that are never reachable from entry)
|
|
87
|
+
const reachable = new Set();
|
|
88
|
+
const queue = [graph.entryNode];
|
|
89
|
+
|
|
90
|
+
while (queue.length > 0) {
|
|
91
|
+
const currentId = queue.shift();
|
|
92
|
+
if (reachable.has(currentId)) continue;
|
|
93
|
+
|
|
94
|
+
reachable.add(currentId);
|
|
95
|
+
const node = graph.nodes.get(currentId);
|
|
96
|
+
if (!node) continue; // Skip if node doesn't exist (handled by dangling ref check)
|
|
97
|
+
|
|
98
|
+
const nextNodes = getNextNodes(node);
|
|
99
|
+
for (const nextId of nextNodes) {
|
|
100
|
+
if (!reachable.has(nextId) && nodeIds.has(nextId)) {
|
|
101
|
+
queue.push(nextId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Find orphaned nodes
|
|
107
|
+
for (const nodeId of nodeIds) {
|
|
108
|
+
if (!reachable.has(nodeId)) {
|
|
109
|
+
errors.push(`Orphaned node "${nodeId}" is unreachable from entry point`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
return { valid: errors.length === 0, errors };
|
|
86
114
|
}
|
|
87
115
|
|
|
@@ -73,7 +73,17 @@ registerFunction('collectErrors', (context) => {
|
|
|
73
73
|
registerFunction('hasCriticalIssues', (context, obj) => {
|
|
74
74
|
if (!obj) return false;
|
|
75
75
|
const json = typeof obj === 'string' ? obj : JSON.stringify(obj);
|
|
76
|
-
|
|
76
|
+
|
|
77
|
+
// Enhanced pattern detection for critical issues
|
|
78
|
+
const criticalPatterns = [
|
|
79
|
+
'"critical"',
|
|
80
|
+
'"blocking"',
|
|
81
|
+
'"P0"',
|
|
82
|
+
'"MUST_FIX"',
|
|
83
|
+
'"URGENT"'
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
return criticalPatterns.some(pattern => json.includes(pattern));
|
|
77
87
|
});
|
|
78
88
|
|
|
79
89
|
registerFunction('generateSummary', (context) => {
|
|
@@ -144,92 +154,162 @@ function parseLiteral(str) {
|
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* @param {
|
|
150
|
-
* @
|
|
151
|
-
* @returns {*}
|
|
157
|
+
* Handle filter syntax: "value | filterName"
|
|
158
|
+
* @param {string} expr
|
|
159
|
+
* @param {object} context
|
|
160
|
+
* @returns {*|null} - Filtered value or null if no match
|
|
152
161
|
*/
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (!filter) {
|
|
164
|
-
throw new TemplateError(`Unknown filter: "${filterName}"`);
|
|
165
|
-
}
|
|
166
|
-
return filter(innerValue);
|
|
162
|
+
function tryResolveFilter(expr, context) {
|
|
163
|
+
// Only match pipes that are NOT inside || (fallback operator)
|
|
164
|
+
const filterMatch = expr.match(/^(.+?)\s*\|\s*(?!\|)(\w+)\s*$/);
|
|
165
|
+
if (!filterMatch) return null;
|
|
166
|
+
|
|
167
|
+
const innerValue = resolveExpression(filterMatch[1], context);
|
|
168
|
+
const filterName = filterMatch[2];
|
|
169
|
+
const filter = _filters.get(filterName);
|
|
170
|
+
if (!filter) {
|
|
171
|
+
throw new TemplateError(`Unknown filter: "${filterName}"`);
|
|
167
172
|
}
|
|
173
|
+
return filter(innerValue);
|
|
174
|
+
}
|
|
168
175
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Handle fallback operator: "expr || default"
|
|
178
|
+
* @param {string} expr
|
|
179
|
+
* @param {object} context
|
|
180
|
+
* @returns {*|null} - Primary or fallback value, or null if no match
|
|
181
|
+
*/
|
|
182
|
+
function tryResolveFallback(expr, context) {
|
|
183
|
+
const fallbackMatch = expr.match(/^(.+?)\s*\|\|\s*(.+)$/);
|
|
184
|
+
if (!fallbackMatch) return null;
|
|
185
|
+
|
|
186
|
+
const primary = resolveExpression(fallbackMatch[1], context);
|
|
187
|
+
if (primary != null && primary !== '') return primary;
|
|
188
|
+
|
|
189
|
+
// Try to resolve the default as a literal or expression
|
|
190
|
+
const fallbackLiteral = parseLiteral(fallbackMatch[2]);
|
|
191
|
+
if (fallbackLiteral.matched) return fallbackLiteral.value;
|
|
192
|
+
return resolveExpression(fallbackMatch[2], context);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle negation: "!expr"
|
|
197
|
+
* @param {string} expr
|
|
198
|
+
* @param {object} context
|
|
199
|
+
* @returns {*|null} - Negated value or null if no match
|
|
200
|
+
*/
|
|
201
|
+
function tryResolveNegation(expr, context) {
|
|
202
|
+
if (!expr.startsWith('!')) return null;
|
|
203
|
+
const inner = resolveExpression(expr.slice(1), context);
|
|
204
|
+
return !inner;
|
|
205
|
+
}
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Handle boolean && expression
|
|
209
|
+
* @param {string} expr
|
|
210
|
+
* @param {object} context
|
|
211
|
+
* @returns {*|null} - Boolean result or null if no match
|
|
212
|
+
*/
|
|
213
|
+
function tryResolveAnd(expr, context) {
|
|
214
|
+
if (!expr.includes('&&')) return null;
|
|
215
|
+
|
|
216
|
+
const parts = expr.split('&&').map(p => p.trim());
|
|
217
|
+
return parts.every(part => {
|
|
218
|
+
const val = resolveExpression(part, context);
|
|
219
|
+
return !!val;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle comparison operators: ===, !==
|
|
225
|
+
* @param {string} expr
|
|
226
|
+
* @param {object} context
|
|
227
|
+
* @returns {*|null} - Comparison result or null if no match
|
|
228
|
+
*/
|
|
229
|
+
function tryResolveComparison(expr, context) {
|
|
230
|
+
const compMatch = expr.match(/^(.+?)\s*(===|!==)\s*(.+)$/);
|
|
231
|
+
if (!compMatch) return null;
|
|
232
|
+
|
|
233
|
+
const left = resolveExpression(compMatch[1], context);
|
|
234
|
+
const right = resolveExpression(compMatch[3], context);
|
|
235
|
+
if (compMatch[2] === '===') return left === right;
|
|
236
|
+
if (compMatch[2] === '!==') return left !== right;
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Handle function call: "functionName(args)"
|
|
242
|
+
* @param {string} expr
|
|
243
|
+
* @param {object} context
|
|
244
|
+
* @returns {*|null} - Function result or null if no match
|
|
245
|
+
*/
|
|
246
|
+
function tryResolveFunctionCall(expr, context) {
|
|
247
|
+
const funcMatch = expr.match(/^(\w+)\(([^)]*)\)$/);
|
|
248
|
+
if (!funcMatch) return null;
|
|
249
|
+
|
|
250
|
+
const funcName = funcMatch[1];
|
|
251
|
+
const fn = _functions.get(funcName);
|
|
252
|
+
if (!fn) {
|
|
253
|
+
throw new TemplateError(`Unknown function: "${funcName}"`);
|
|
184
254
|
}
|
|
185
255
|
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
return parts.every(part => {
|
|
190
|
-
const val = resolveExpression(part, context);
|
|
191
|
-
return !!val;
|
|
192
|
-
});
|
|
256
|
+
const argsStr = funcMatch[2].trim();
|
|
257
|
+
if (argsStr === '') {
|
|
258
|
+
return fn(context);
|
|
193
259
|
}
|
|
194
260
|
|
|
195
|
-
//
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
const right = resolveExpression(compMatch[3], context);
|
|
200
|
-
if (compMatch[2] === '===') return left === right;
|
|
201
|
-
if (compMatch[2] === '!==') return left !== right;
|
|
261
|
+
// Parse argument: could be a path reference or a quoted string
|
|
262
|
+
const argLiteral = parseLiteral(argsStr);
|
|
263
|
+
if (argLiteral.matched) {
|
|
264
|
+
return fn(context, argLiteral.value);
|
|
202
265
|
}
|
|
203
266
|
|
|
204
|
-
//
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
267
|
+
// Resolve as expression (path)
|
|
268
|
+
const argValue = resolveExpression(argsStr, context);
|
|
269
|
+
return fn(context, argValue);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve a single expression string (the content inside {{ }}).
|
|
274
|
+
*
|
|
275
|
+
* Delegates to specialized handlers in order:
|
|
276
|
+
* 1. Filter syntax: "value | filterName"
|
|
277
|
+
* 2. Fallback operator: "expr || default"
|
|
278
|
+
* 3. Negation: "!expr"
|
|
279
|
+
* 4. Boolean AND: "a && b"
|
|
280
|
+
* 5. Comparison: "a === b", "a !== b"
|
|
281
|
+
* 6. Function call: "functionName(args)"
|
|
282
|
+
* 7. Literal value: true, false, number, string, [], {}
|
|
283
|
+
* 8. Path resolution: "inputs.task", "nodes.X.outputs.Y"
|
|
284
|
+
*
|
|
285
|
+
* @param {string} expr – raw expression without {{ }}
|
|
286
|
+
* @param {object} context – { inputs, nodes, steps, state }
|
|
287
|
+
* @returns {*}
|
|
288
|
+
*/
|
|
289
|
+
function resolveExpression(expr, context) {
|
|
290
|
+
const trimmed = expr.trim();
|
|
291
|
+
|
|
292
|
+
// Try each expression type in order (most specific to least specific)
|
|
293
|
+
const handlers = [
|
|
294
|
+
tryResolveFilter,
|
|
295
|
+
tryResolveFallback,
|
|
296
|
+
tryResolveNegation,
|
|
297
|
+
tryResolveAnd,
|
|
298
|
+
tryResolveComparison,
|
|
299
|
+
tryResolveFunctionCall
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const handler of handlers) {
|
|
303
|
+
const result = handler(trimmed, context);
|
|
304
|
+
if (result !== null) return result;
|
|
224
305
|
}
|
|
225
306
|
|
|
226
|
-
//
|
|
307
|
+
// Try literal value
|
|
227
308
|
const literal = parseLiteral(trimmed);
|
|
228
309
|
if (literal.matched) return literal.value;
|
|
229
310
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
return value;
|
|
311
|
+
// Default: path resolution (most common case)
|
|
312
|
+
return resolvePath(context, trimmed);
|
|
233
313
|
}
|
|
234
314
|
|
|
235
315
|
/**
|