elsabro 7.3.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 +322 -5
- 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 +134 -3
- 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 +3 -3
- package/hooks/skill-discovery.sh +0 -0
- package/package.json +1 -1
|
@@ -54,6 +54,9 @@ class CallbackProtocol {
|
|
|
54
54
|
requiresTeam(branches) {
|
|
55
55
|
if (!Array.isArray(branches) || branches.length < 2) return false;
|
|
56
56
|
|
|
57
|
+
// Validate each branch before accessing config
|
|
58
|
+
this._validateBranches(branches, 'requiresTeam');
|
|
59
|
+
|
|
57
60
|
const allHaiku = branches.every(b =>
|
|
58
61
|
b.config && b.config.model === 'haiku'
|
|
59
62
|
);
|
|
@@ -90,6 +93,9 @@ class CallbackProtocol {
|
|
|
90
93
|
* @returns {{ name: string, agent: string, role: string, model: string }[]}
|
|
91
94
|
*/
|
|
92
95
|
composeTeam(branches, nodeContext = 'default') {
|
|
96
|
+
// Validate branches before processing
|
|
97
|
+
this._validateBranches(branches, 'composeTeam');
|
|
98
|
+
|
|
93
99
|
// 1. Start with branch agents
|
|
94
100
|
const members = branches.map((b, i) => ({
|
|
95
101
|
name: `${b.id}-${i + 1}`,
|
|
@@ -127,6 +133,9 @@ class CallbackProtocol {
|
|
|
127
133
|
* @returns {object}
|
|
128
134
|
*/
|
|
129
135
|
buildParallelInstruction(nodeId, branches, context = {}) {
|
|
136
|
+
// Validate branches before processing
|
|
137
|
+
this._validateBranches(branches, 'buildParallelInstruction');
|
|
138
|
+
|
|
130
139
|
const useAgentTeams = this.requiresTeam(branches);
|
|
131
140
|
|
|
132
141
|
const instruction = {
|
|
@@ -251,6 +260,76 @@ class CallbackProtocol {
|
|
|
251
260
|
|
|
252
261
|
// ---------- Internal ----------
|
|
253
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Validate a single branch object structure.
|
|
265
|
+
* @private
|
|
266
|
+
* @param {*} branch - Branch object to validate
|
|
267
|
+
* @param {number} index - Index in branches array (for error messages)
|
|
268
|
+
* @param {string} methodName - Name of calling method (for error messages)
|
|
269
|
+
* @throws {Error} If branch is invalid
|
|
270
|
+
*/
|
|
271
|
+
_validateBranch(branch, index, methodName) {
|
|
272
|
+
if (!branch || typeof branch !== 'object') {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
|
|
275
|
+
`expected object, got ${typeof branch}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!branch.id || typeof branch.id !== 'string') {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
|
|
282
|
+
`missing or invalid "id" property (expected non-empty string)`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!branch.agent || typeof branch.agent !== 'string') {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
|
|
289
|
+
`missing or invalid "agent" property (expected non-empty string)`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Validate config if present (optional but must be object if provided)
|
|
294
|
+
if (branch.config !== undefined && branch.config !== null) {
|
|
295
|
+
if (typeof branch.config !== 'object') {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
|
|
298
|
+
`"config" must be an object, got ${typeof branch.config}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Validate model if present in config
|
|
303
|
+
if (branch.config.model !== undefined &&
|
|
304
|
+
typeof branch.config.model !== 'string') {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
|
|
307
|
+
`"config.model" must be a string, got ${typeof branch.config.model}`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate branches array structure.
|
|
315
|
+
* @private
|
|
316
|
+
* @param {*} branches - Branches array to validate
|
|
317
|
+
* @param {string} methodName - Name of calling method (for error messages)
|
|
318
|
+
* @throws {Error} If branches array or any branch is invalid
|
|
319
|
+
*/
|
|
320
|
+
_validateBranches(branches, methodName) {
|
|
321
|
+
if (!Array.isArray(branches)) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`[CallbackProtocol.${methodName}] Invalid branches parameter: ` +
|
|
324
|
+
`expected array, got ${typeof branches}`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
branches.forEach((branch, index) => {
|
|
329
|
+
this._validateBranch(branch, index, methodName);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
254
333
|
/**
|
|
255
334
|
* Get ordered list of support agents for padding, excluding those already in the team.
|
|
256
335
|
* @private
|
|
@@ -107,6 +107,47 @@ class CheckpointManager {
|
|
|
107
107
|
|
|
108
108
|
return toRemove.length;
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Auto-cleanup checkpoints older than a specified number of days.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} [flowId] – optional flow identifier (if not provided, cleans all flows)
|
|
115
|
+
* @param {number} [daysOld=7] – remove checkpoints older than this many days
|
|
116
|
+
* @returns {number} number of checkpoints removed
|
|
117
|
+
*/
|
|
118
|
+
autoCleanup(flowId = null, daysOld = 7) {
|
|
119
|
+
if (!fs.existsSync(this.dir)) return 0;
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const maxAge = daysOld * 24 * 60 * 60 * 1000; // convert days to milliseconds
|
|
123
|
+
const cutoffTimestamp = now - maxAge;
|
|
124
|
+
|
|
125
|
+
let files = fs.readdirSync(this.dir).filter(f => f.endsWith('.json'));
|
|
126
|
+
|
|
127
|
+
// If flowId specified, filter to only that flow
|
|
128
|
+
if (flowId) {
|
|
129
|
+
files = files.filter(f => f.startsWith(`${flowId}-`));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let removedCount = 0;
|
|
133
|
+
|
|
134
|
+
for (const filename of files) {
|
|
135
|
+
// Extract timestamp from filename pattern: flowId-timestamp-seq.json
|
|
136
|
+
const match = filename.match(/-(\d+)-\d+\.json$/);
|
|
137
|
+
if (match) {
|
|
138
|
+
const timestamp = parseInt(match[1], 10);
|
|
139
|
+
if (timestamp < cutoffTimestamp) {
|
|
140
|
+
const filepath = path.join(this.dir, filename);
|
|
141
|
+
if (fs.existsSync(filepath)) {
|
|
142
|
+
fs.unlinkSync(filepath);
|
|
143
|
+
removedCount++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return removedCount;
|
|
150
|
+
}
|
|
110
151
|
}
|
|
111
152
|
|
|
112
153
|
module.exports = { CheckpointManager };
|
package/flow-engine/src/cli.js
CHANGED
|
@@ -22,6 +22,152 @@ const { resolveTemplate } = require('./template');
|
|
|
22
22
|
const { checkRuntimeStatus, NotImplementedError, DeprecatedNodeError } = require('./executors');
|
|
23
23
|
const { serializeContext, deserializeContext } = require('./runner');
|
|
24
24
|
|
|
25
|
+
// ---------- State and Context Management ----------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Update state.json with current flow execution state.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} flowId - Flow identifier
|
|
31
|
+
* @param {object} checkpoint - Current checkpoint data
|
|
32
|
+
* @param {string} phase - Current execution phase
|
|
33
|
+
* @param {object} [metadata] - Additional metadata
|
|
34
|
+
*/
|
|
35
|
+
function updateStateJson(flowId, checkpoint, phase, metadata = {}) {
|
|
36
|
+
const stateFile = path.join(process.cwd(), '.elsabro', 'state.json');
|
|
37
|
+
let state = {};
|
|
38
|
+
|
|
39
|
+
// Read existing state
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(stateFile)) {
|
|
42
|
+
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Initialize new state if file doesn't exist or is corrupted
|
|
46
|
+
state = {
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
created_at: new Date().toISOString(),
|
|
49
|
+
updated_at: new Date().toISOString(),
|
|
50
|
+
current_flow: null,
|
|
51
|
+
context: {},
|
|
52
|
+
history: [],
|
|
53
|
+
pending_tasks: [],
|
|
54
|
+
blocked_on: null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Update state with current flow info
|
|
59
|
+
state.updated_at = new Date().toISOString();
|
|
60
|
+
state.current_flow = {
|
|
61
|
+
command: 'flow-engine',
|
|
62
|
+
flowId,
|
|
63
|
+
phase,
|
|
64
|
+
currentNode: checkpoint.currentNode,
|
|
65
|
+
nextNode: checkpoint.nextNode,
|
|
66
|
+
nodesVisited: checkpoint.nodesVisited || [],
|
|
67
|
+
started_at: metadata.started_at || state.current_flow?.started_at || new Date().toISOString()
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Merge context from checkpoint
|
|
71
|
+
if (checkpoint.context) {
|
|
72
|
+
const ctx = typeof checkpoint.context === 'string'
|
|
73
|
+
? deserializeContext(checkpoint.context)
|
|
74
|
+
: checkpoint.context;
|
|
75
|
+
state.context = { ...state.context, ...ctx };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add metadata
|
|
79
|
+
if (metadata.error) {
|
|
80
|
+
state.current_flow.error = metadata.error;
|
|
81
|
+
}
|
|
82
|
+
if (metadata.finished) {
|
|
83
|
+
state.current_flow.finished = true;
|
|
84
|
+
state.current_flow.completed_at = new Date().toISOString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Ensure directory exists
|
|
88
|
+
const stateDir = path.dirname(stateFile);
|
|
89
|
+
if (!fs.existsSync(stateDir)) {
|
|
90
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Write state
|
|
94
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update context.md with human-readable summary.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} flowId - Flow identifier
|
|
101
|
+
* @param {object} checkpoint - Current checkpoint data
|
|
102
|
+
* @param {string} phase - Current execution phase
|
|
103
|
+
* @param {object} [state] - Current state object
|
|
104
|
+
*/
|
|
105
|
+
function updateContextMd(flowId, checkpoint, phase, state = null) {
|
|
106
|
+
const contextFile = path.join(process.cwd(), '.elsabro', 'context.md');
|
|
107
|
+
|
|
108
|
+
// Load state if not provided
|
|
109
|
+
if (!state) {
|
|
110
|
+
const stateFile = path.join(process.cwd(), '.elsabro', 'state.json');
|
|
111
|
+
try {
|
|
112
|
+
if (fs.existsSync(stateFile)) {
|
|
113
|
+
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
state = { context: {}, history: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const ctx = state.context || {};
|
|
122
|
+
const history = state.history || [];
|
|
123
|
+
|
|
124
|
+
let content = `# Estado Actual del Proyecto\n\n`;
|
|
125
|
+
content += `**Última actualización**: ${now}\n`;
|
|
126
|
+
content += `**Comando actual**: flow-engine (${flowId})\n`;
|
|
127
|
+
content += `**Fase**: ${phase}\n`;
|
|
128
|
+
content += `**Nodo actual**: ${checkpoint.currentNode || 'none'}\n`;
|
|
129
|
+
content += `**Siguiente nodo**: ${checkpoint.nextNode || 'none'}\n\n`;
|
|
130
|
+
|
|
131
|
+
content += `## Progreso del Flow\n`;
|
|
132
|
+
content += `- **Nodos visitados**: ${(checkpoint.nodesVisited || []).length}\n`;
|
|
133
|
+
content += `- **Nodos recientes**: ${(checkpoint.nodesVisited || []).slice(-5).join(', ') || 'none'}\n\n`;
|
|
134
|
+
|
|
135
|
+
if (ctx.inputs) {
|
|
136
|
+
content += `## Contexto de Entrada\n`;
|
|
137
|
+
content += `- **Task**: ${ctx.inputs.task || 'N/A'}\n`;
|
|
138
|
+
content += `- **Profile**: ${ctx.inputs.profile || 'N/A'}\n`;
|
|
139
|
+
content += `- **Complexity**: ${ctx.inputs.complexity || 'N/A'}\n\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (history.length > 0) {
|
|
143
|
+
content += `## Historial Reciente\n`;
|
|
144
|
+
history.slice(-3).reverse().forEach((entry, idx) => {
|
|
145
|
+
content += `${idx + 1}. ${entry.command} - ${entry.result} (${entry.completed_at || 'N/A'})\n`;
|
|
146
|
+
});
|
|
147
|
+
content += `\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
content += `## Para Continuar\n`;
|
|
151
|
+
content += `\`\`\`bash\n`;
|
|
152
|
+
if (checkpoint.nextNode) {
|
|
153
|
+
content += `# Flow en progreso - ejecutar siguiente paso\n`;
|
|
154
|
+
content += `node flow-engine/src/cli.js step --flow flows/development-flow.json\n`;
|
|
155
|
+
} else {
|
|
156
|
+
content += `# Flow completado o sin inicializar\n`;
|
|
157
|
+
content += `node flow-engine/src/cli.js status --flow flows/development-flow.json\n`;
|
|
158
|
+
}
|
|
159
|
+
content += `\`\`\`\n`;
|
|
160
|
+
|
|
161
|
+
// Ensure directory exists
|
|
162
|
+
const contextDir = path.dirname(contextFile);
|
|
163
|
+
if (!fs.existsSync(contextDir)) {
|
|
164
|
+
fs.mkdirSync(contextDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Write context
|
|
168
|
+
fs.writeFileSync(contextFile, content);
|
|
169
|
+
}
|
|
170
|
+
|
|
25
171
|
// ---------- Argument parsing ----------
|
|
26
172
|
|
|
27
173
|
function parseArgs(argv) {
|
|
@@ -238,12 +384,20 @@ function cmdInit(args) {
|
|
|
238
384
|
|
|
239
385
|
const entryNode = engine.graph.entryNode;
|
|
240
386
|
|
|
241
|
-
|
|
387
|
+
const checkpointData = {
|
|
242
388
|
currentNode: null,
|
|
243
389
|
nextNode: entryNode,
|
|
244
390
|
context: serializeContext(context),
|
|
245
391
|
nodesVisited: []
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
cpManager.save(flowId, checkpointData);
|
|
395
|
+
|
|
396
|
+
// Update state.json and context.md
|
|
397
|
+
updateStateJson(flowId, checkpointData, 'initialized', {
|
|
398
|
+
started_at: new Date().toISOString()
|
|
246
399
|
});
|
|
400
|
+
updateContextMd(flowId, checkpointData, 'initialized');
|
|
247
401
|
|
|
248
402
|
return {
|
|
249
403
|
initialized: true,
|
|
@@ -307,6 +461,17 @@ async function cmdStep(args) {
|
|
|
307
461
|
continue;
|
|
308
462
|
}
|
|
309
463
|
if (err instanceof NotImplementedError) {
|
|
464
|
+
// Update state with error before returning
|
|
465
|
+
const errorCheckpoint = {
|
|
466
|
+
currentNode: node.id,
|
|
467
|
+
nextNode: currentNodeId,
|
|
468
|
+
context: serializeContext(context),
|
|
469
|
+
nodesVisited: [...nodesVisited]
|
|
470
|
+
};
|
|
471
|
+
updateStateJson(flowId, errorCheckpoint, 'error', {
|
|
472
|
+
error: err.message
|
|
473
|
+
});
|
|
474
|
+
updateContextMd(flowId, errorCheckpoint, 'error');
|
|
310
475
|
return { error: err.message, stoppedAt: node.id };
|
|
311
476
|
}
|
|
312
477
|
}
|
|
@@ -327,12 +492,20 @@ async function cmdStep(args) {
|
|
|
327
492
|
nodesVisited.push(node.id);
|
|
328
493
|
context.nodes[node.id] = { outputs: resolvedOutputs };
|
|
329
494
|
|
|
330
|
-
|
|
495
|
+
const exitCheckpoint = {
|
|
331
496
|
currentNode: node.id,
|
|
332
497
|
nextNode: null,
|
|
333
498
|
context: serializeContext(context),
|
|
334
499
|
nodesVisited: [...nodesVisited]
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
cpManager.save(flowId, exitCheckpoint);
|
|
503
|
+
|
|
504
|
+
// Update state.json and context.md for completion
|
|
505
|
+
updateStateJson(flowId, exitCheckpoint, 'done', {
|
|
506
|
+
finished: true
|
|
335
507
|
});
|
|
508
|
+
updateContextMd(flowId, exitCheckpoint, 'done');
|
|
336
509
|
|
|
337
510
|
return {
|
|
338
511
|
finished: true,
|
|
@@ -430,12 +603,18 @@ async function cmdStep(args) {
|
|
|
430
603
|
}
|
|
431
604
|
|
|
432
605
|
// Save checkpoint at this actionable node
|
|
433
|
-
|
|
606
|
+
const actionCheckpoint = {
|
|
434
607
|
currentNode: node.id,
|
|
435
608
|
nextNode: currentNodeId,
|
|
436
609
|
context: serializeContext(context),
|
|
437
610
|
nodesVisited: [...nodesVisited]
|
|
438
|
-
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
cpManager.save(flowId, actionCheckpoint);
|
|
614
|
+
|
|
615
|
+
// Update state.json and context.md
|
|
616
|
+
updateStateJson(flowId, actionCheckpoint, 'stepping');
|
|
617
|
+
updateContextMd(flowId, actionCheckpoint, 'stepping');
|
|
439
618
|
|
|
440
619
|
return {
|
|
441
620
|
instruction,
|
|
@@ -446,6 +625,17 @@ async function cmdStep(args) {
|
|
|
446
625
|
}
|
|
447
626
|
|
|
448
627
|
// Reached end without actionable node
|
|
628
|
+
const finishedCheckpoint = {
|
|
629
|
+
currentNode: null,
|
|
630
|
+
nextNode: null,
|
|
631
|
+
context: serializeContext(context),
|
|
632
|
+
nodesVisited: [...nodesVisited]
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// Update state for finished flow
|
|
636
|
+
updateStateJson(flowId, finishedCheckpoint, 'done', { finished: true });
|
|
637
|
+
updateContextMd(flowId, finishedCheckpoint, 'done');
|
|
638
|
+
|
|
449
639
|
return { finished: true, nodesVisited };
|
|
450
640
|
}
|
|
451
641
|
|
|
@@ -514,12 +704,21 @@ function cmdComplete(args) {
|
|
|
514
704
|
}
|
|
515
705
|
}
|
|
516
706
|
|
|
517
|
-
|
|
707
|
+
const completedCheckpoint = {
|
|
518
708
|
currentNode: node.id,
|
|
519
709
|
nextNode,
|
|
520
710
|
context: serializeContext(context),
|
|
521
711
|
nodesVisited: [...nodesVisited]
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
cpManager.save(flowId, completedCheckpoint);
|
|
715
|
+
|
|
716
|
+
// Update state.json and context.md
|
|
717
|
+
const phase = nextNode ? 'stepping' : 'done';
|
|
718
|
+
updateStateJson(flowId, completedCheckpoint, phase, {
|
|
719
|
+
finished: !nextNode
|
|
522
720
|
});
|
|
721
|
+
updateContextMd(flowId, completedCheckpoint, phase);
|
|
523
722
|
|
|
524
723
|
return {
|
|
525
724
|
completed: true,
|
|
@@ -594,4 +793,13 @@ if (require.main === module) {
|
|
|
594
793
|
});
|
|
595
794
|
}
|
|
596
795
|
|
|
597
|
-
module.exports = {
|
|
796
|
+
module.exports = {
|
|
797
|
+
main,
|
|
798
|
+
parseArgs,
|
|
799
|
+
flowIdFromPath,
|
|
800
|
+
isAutoResolvable,
|
|
801
|
+
detectNodeContext,
|
|
802
|
+
updateStateJson,
|
|
803
|
+
updateContextMd,
|
|
804
|
+
COMMANDS
|
|
805
|
+
};
|
|
@@ -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
497
|
// Agent Teams are now handled via parallel dispatch in execute.md#3-dispatch + callbacks.js
|
|
364
|
-
if (node.runtime_status === 'not_implemented') {
|
|
365
|
-
throw new NotImplementedError(node.id, node.gaps);
|
|
366
|
-
}
|
|
367
498
|
|
|
368
499
|
// If somehow called on a non-deprecated team node
|
|
369
500
|
if (callbacks.onTeam) {
|