elsabro 6.0.0 → 7.0.0
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-orchestrator.md +39 -0
- package/bin/install.js +8 -4
- package/commands/elsabro/add-phase.md +20 -0
- package/commands/elsabro/complete-milestone.md +20 -0
- package/commands/elsabro/design-ui.md +21 -0
- package/commands/elsabro/execute.md +132 -12
- package/commands/elsabro/new-milestone.md +23 -0
- package/commands/elsabro/party.md +80 -23
- package/commands/elsabro/quick.md +19 -2
- package/flow-engine/src/agent-cards.json +74 -0
- package/flow-engine/src/callbacks.js +268 -0
- package/flow-engine/src/cli.js +597 -0
- package/flow-engine/src/executors.js +14 -1
- package/flow-engine/src/index.js +4 -2
- package/flow-engine/src/party.js +414 -0
- package/flow-engine/src/runner.js +5 -5
- package/flow-engine/tests/callbacks.test.js +274 -0
- package/flow-engine/tests/cli.test.js +208 -0
- package/flow-engine/tests/executors-complex.test.js +61 -1
- package/flow-engine/tests/integration.test.js +667 -38
- package/flow-engine/tests/party.test.js +724 -0
- package/flows/development-flow.json +104 -120
- package/package.json +5 -3
- package/references/next-step-engine.md +19 -0
- package/references/state-sync.md +15 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CLI entry point for the ELSABRO flow engine.
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* validate --flow <path> Validate flow, report parallel nodes + team compositions
|
|
9
|
+
* dry-run --flow <path> --task <str> --profile <str> Full run with mock callbacks
|
|
10
|
+
* init --flow <path> --task <str> --profile <str> Create initial checkpoint
|
|
11
|
+
* step --flow <path> Advance to next actionable node
|
|
12
|
+
* complete --flow <path> --result <json> Apply result, advance checkpoint
|
|
13
|
+
* status --flow <path> Show current state
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { FlowEngine } = require('./index');
|
|
19
|
+
const { CheckpointManager } = require('./checkpoint');
|
|
20
|
+
const { CallbackProtocol } = require('./callbacks');
|
|
21
|
+
const { resolveTemplate } = require('./template');
|
|
22
|
+
const { checkRuntimeStatus, NotImplementedError, DeprecatedNodeError } = require('./executors');
|
|
23
|
+
const { serializeContext, deserializeContext } = require('./runner');
|
|
24
|
+
|
|
25
|
+
// ---------- Argument parsing ----------
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = {};
|
|
29
|
+
let command = null;
|
|
30
|
+
|
|
31
|
+
for (let i = 2; i < argv.length; i++) {
|
|
32
|
+
const arg = argv[i];
|
|
33
|
+
if (!arg.startsWith('--') && !command) {
|
|
34
|
+
command = arg;
|
|
35
|
+
} else if (arg.startsWith('--')) {
|
|
36
|
+
const key = arg.slice(2);
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
if (next && !next.startsWith('--')) {
|
|
39
|
+
args[key] = next;
|
|
40
|
+
i++;
|
|
41
|
+
} else {
|
|
42
|
+
args[key] = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { command, args };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- Flow ID from path ----------
|
|
51
|
+
|
|
52
|
+
function flowIdFromPath(flowPath) {
|
|
53
|
+
const basename = path.basename(flowPath, '.json');
|
|
54
|
+
return basename.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------- Node classification ----------
|
|
58
|
+
|
|
59
|
+
const AUTO_RESOLVABLE = new Set(['entry', 'condition', 'router', 'exit']);
|
|
60
|
+
|
|
61
|
+
function isAutoResolvable(node) {
|
|
62
|
+
if (AUTO_RESOLVABLE.has(node.type)) return true;
|
|
63
|
+
// Deprecated team nodes are auto-skipped
|
|
64
|
+
if (node.type === 'team' && node.runtime_status === 'deprecated') return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- Detect node context for team padding ----------
|
|
69
|
+
|
|
70
|
+
function detectNodeContext(nodeId) {
|
|
71
|
+
if (nodeId.includes('implementation') || nodeId.includes('implement')) return 'implementation';
|
|
72
|
+
if (nodeId.includes('review') || nodeId.includes('check')) return 'review';
|
|
73
|
+
if (nodeId.includes('fix') || nodeId.includes('debug') || nodeId.includes('error')) return 'fix';
|
|
74
|
+
return 'default';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- Commands ----------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* validate: Parse and validate flow, report parallel nodes + team compositions.
|
|
81
|
+
*/
|
|
82
|
+
function cmdValidate(args) {
|
|
83
|
+
const flowPath = args.flow;
|
|
84
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
85
|
+
|
|
86
|
+
const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
87
|
+
const engine = new FlowEngine();
|
|
88
|
+
engine.loadFlow(flowJson);
|
|
89
|
+
|
|
90
|
+
const protocol = new CallbackProtocol();
|
|
91
|
+
const parallelNodes = engine.getNodesWhere(n => n.type === 'parallel');
|
|
92
|
+
|
|
93
|
+
const teamAnalysis = parallelNodes.map(node => {
|
|
94
|
+
const branches = node.branches || [];
|
|
95
|
+
const needsTeam = protocol.requiresTeam(branches);
|
|
96
|
+
const nodeContext = detectNodeContext(node.id);
|
|
97
|
+
|
|
98
|
+
let team = null;
|
|
99
|
+
if (needsTeam) {
|
|
100
|
+
const members = protocol.composeTeam(branches, nodeContext);
|
|
101
|
+
team = {
|
|
102
|
+
name: protocol.generateTeamName(node.id),
|
|
103
|
+
memberCount: members.length,
|
|
104
|
+
members: members.map(m => ({ name: m.name, agent: m.agent, role: m.role }))
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
nodeId: node.id,
|
|
110
|
+
branchCount: branches.length,
|
|
111
|
+
useAgentTeams: needsTeam,
|
|
112
|
+
reason: needsTeam ? `${branches.length} non-haiku branches` : 'all-haiku (subagents)',
|
|
113
|
+
team
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
valid: true,
|
|
119
|
+
flowId: engine.getFlowMetadata().id,
|
|
120
|
+
nodeCount: engine.getNodeCount(),
|
|
121
|
+
parallelNodes: teamAnalysis
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* dry-run: Run full flow with mock callbacks, report path + team actions.
|
|
127
|
+
*/
|
|
128
|
+
async function cmdDryRun(args) {
|
|
129
|
+
const flowPath = args.flow;
|
|
130
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
131
|
+
|
|
132
|
+
const task = args.task || 'dry-run task';
|
|
133
|
+
const profile = args.profile || 'default';
|
|
134
|
+
const complexity = args.complexity || 'medium';
|
|
135
|
+
|
|
136
|
+
const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
137
|
+
const engine = new FlowEngine();
|
|
138
|
+
engine.loadFlow(flowJson);
|
|
139
|
+
|
|
140
|
+
const protocol = new CallbackProtocol();
|
|
141
|
+
const teamActions = [];
|
|
142
|
+
|
|
143
|
+
const callbacks = {
|
|
144
|
+
onAgent: async (params) => {
|
|
145
|
+
// Return passed:true for verifier agents so verify_check conditions pass
|
|
146
|
+
if (params.agent === 'elsabro-verifier' || (params.id && params.id.includes('verify'))) {
|
|
147
|
+
return { passed: true, result: 'mock' };
|
|
148
|
+
}
|
|
149
|
+
return { result: 'mock', agent: params.agent };
|
|
150
|
+
},
|
|
151
|
+
onParallel: async (params) => {
|
|
152
|
+
const instruction = protocol.buildParallelInstruction(
|
|
153
|
+
params.nodeId,
|
|
154
|
+
params.branches,
|
|
155
|
+
{ nodeContext: detectNodeContext(params.nodeId), taskSlug: task }
|
|
156
|
+
);
|
|
157
|
+
if (instruction.useAgentTeams) {
|
|
158
|
+
teamActions.push({
|
|
159
|
+
nodeId: params.nodeId,
|
|
160
|
+
teamName: instruction.team.name,
|
|
161
|
+
memberCount: instruction.team.members.length,
|
|
162
|
+
members: instruction.team.members.map(m => m.agent)
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return params.branches.map(b => ({ id: b.id, result: 'mock' }));
|
|
166
|
+
},
|
|
167
|
+
onInterrupt: async (params) => {
|
|
168
|
+
const routes = params.routes || {};
|
|
169
|
+
const options = (params.display && params.display.options) || [];
|
|
170
|
+
// Auto-select: prefer 'approve' or first option
|
|
171
|
+
const approveOpt = options.find(o => o.id === 'approve' || o.id === 'continue');
|
|
172
|
+
if (approveOpt) return approveOpt.id;
|
|
173
|
+
const keys = Object.keys(routes);
|
|
174
|
+
return keys.length > 0 ? keys[0] : null;
|
|
175
|
+
},
|
|
176
|
+
onBash: async (cmd) => ({ output: 'mock', exitCode: 0 }),
|
|
177
|
+
onReadFiles: async (files) => ({ content: 'mock' }),
|
|
178
|
+
onNodeComplete: async (nodeId, result, ctx) => {
|
|
179
|
+
// For sequence nodes without explicit outputs: copy step data into node
|
|
180
|
+
// outputs so downstream conditions (e.g. quality_check) can reference them.
|
|
181
|
+
if (ctx) {
|
|
182
|
+
const node = engine.graph.nodes.get(nodeId);
|
|
183
|
+
if (node && node.type === 'sequence' && !node.outputs) {
|
|
184
|
+
const stepOutputs = {};
|
|
185
|
+
for (const step of (node.steps || [])) {
|
|
186
|
+
if (step.as && ctx.steps[step.as]) {
|
|
187
|
+
stepOutputs[step.as] = ctx.steps[step.as].output || {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(stepOutputs).length > 0) {
|
|
191
|
+
ctx.nodes[nodeId] = ctx.nodes[nodeId] || {};
|
|
192
|
+
ctx.nodes[nodeId].outputs = stepOutputs;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const result = await engine.run(
|
|
200
|
+
{ task, profile, complexity },
|
|
201
|
+
callbacks
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
success: result.success,
|
|
206
|
+
nodesVisited: result.nodesVisited,
|
|
207
|
+
nodeCount: result.nodesVisited.length,
|
|
208
|
+
teamActions,
|
|
209
|
+
log: protocol.getLog()
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* init: Create initial checkpoint for a flow.
|
|
215
|
+
*/
|
|
216
|
+
function cmdInit(args) {
|
|
217
|
+
const flowPath = args.flow;
|
|
218
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
219
|
+
|
|
220
|
+
const task = args.task || '';
|
|
221
|
+
const profile = args.profile || 'default';
|
|
222
|
+
const complexity = args.complexity || 'medium';
|
|
223
|
+
|
|
224
|
+
const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
225
|
+
const engine = new FlowEngine();
|
|
226
|
+
engine.loadFlow(flowJson);
|
|
227
|
+
|
|
228
|
+
const flowId = flowIdFromPath(flowPath);
|
|
229
|
+
const cpManager = new CheckpointManager();
|
|
230
|
+
|
|
231
|
+
const context = {
|
|
232
|
+
inputs: { task, profile, complexity },
|
|
233
|
+
nodes: {},
|
|
234
|
+
steps: {},
|
|
235
|
+
state: {},
|
|
236
|
+
_iterations: {}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const entryNode = engine.graph.entryNode;
|
|
240
|
+
|
|
241
|
+
cpManager.save(flowId, {
|
|
242
|
+
currentNode: null,
|
|
243
|
+
nextNode: entryNode,
|
|
244
|
+
context: serializeContext(context),
|
|
245
|
+
nodesVisited: []
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
initialized: true,
|
|
250
|
+
flowId,
|
|
251
|
+
entryNode,
|
|
252
|
+
task,
|
|
253
|
+
profile
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* step: Advance through auto-resolvable nodes, stop at actionable node.
|
|
259
|
+
*/
|
|
260
|
+
async function cmdStep(args) {
|
|
261
|
+
const flowPath = args.flow;
|
|
262
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
263
|
+
|
|
264
|
+
const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
265
|
+
const engine = new FlowEngine();
|
|
266
|
+
engine.loadFlow(flowJson);
|
|
267
|
+
|
|
268
|
+
const flowId = flowIdFromPath(flowPath);
|
|
269
|
+
const cpManager = new CheckpointManager();
|
|
270
|
+
const checkpoint = cpManager.load(flowId);
|
|
271
|
+
|
|
272
|
+
if (!checkpoint) {
|
|
273
|
+
return { error: 'No checkpoint found. Run init first.' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const protocol = new CallbackProtocol();
|
|
277
|
+
const context = deserializeContext(checkpoint.context);
|
|
278
|
+
const nodesVisited = checkpoint.nodesVisited || [];
|
|
279
|
+
let currentNodeId = checkpoint.nextNode;
|
|
280
|
+
|
|
281
|
+
if (currentNodeId === null) {
|
|
282
|
+
return { finished: true, nodesVisited };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const maxSteps = 50;
|
|
286
|
+
let stepCount = 0;
|
|
287
|
+
|
|
288
|
+
while (currentNodeId !== null) {
|
|
289
|
+
stepCount++;
|
|
290
|
+
if (stepCount > maxSteps) {
|
|
291
|
+
return { error: 'Exceeded max auto-resolve steps (50). Possible loop.' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const node = engine.graph.nodes.get(currentNodeId);
|
|
295
|
+
if (!node) {
|
|
296
|
+
return { error: `Node "${currentNodeId}" not found in graph` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check for deprecated/not-implemented
|
|
300
|
+
try {
|
|
301
|
+
checkRuntimeStatus(node);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (err instanceof DeprecatedNodeError) {
|
|
304
|
+
// Skip deprecated nodes
|
|
305
|
+
nodesVisited.push(node.id);
|
|
306
|
+
currentNodeId = node.next || null;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (err instanceof NotImplementedError) {
|
|
310
|
+
return { error: err.message, stoppedAt: node.id };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// If auto-resolvable, execute inline
|
|
315
|
+
if (isAutoResolvable(node)) {
|
|
316
|
+
let result;
|
|
317
|
+
|
|
318
|
+
switch (node.type) {
|
|
319
|
+
case 'entry':
|
|
320
|
+
result = { next: node.next, outputs: {} };
|
|
321
|
+
break;
|
|
322
|
+
|
|
323
|
+
case 'exit': {
|
|
324
|
+
const resolvedOutputs = node.outputs
|
|
325
|
+
? resolveTemplate(node.outputs, context)
|
|
326
|
+
: {};
|
|
327
|
+
nodesVisited.push(node.id);
|
|
328
|
+
context.nodes[node.id] = { outputs: resolvedOutputs };
|
|
329
|
+
|
|
330
|
+
cpManager.save(flowId, {
|
|
331
|
+
currentNode: node.id,
|
|
332
|
+
nextNode: null,
|
|
333
|
+
context: serializeContext(context),
|
|
334
|
+
nodesVisited: [...nodesVisited]
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
finished: true,
|
|
339
|
+
exitNode: node.id,
|
|
340
|
+
status: node.status || 'completed',
|
|
341
|
+
nodesVisited
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'condition': {
|
|
346
|
+
const conditionStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
|
|
347
|
+
? node.condition
|
|
348
|
+
: `{{${node.condition}}}`;
|
|
349
|
+
const condResult = resolveTemplate(conditionStr, context);
|
|
350
|
+
result = { next: condResult ? node.true : node.false, outputs: { conditionResult: !!condResult } };
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'router': {
|
|
355
|
+
const condStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
|
|
356
|
+
? node.condition
|
|
357
|
+
: `{{${node.condition}}}`;
|
|
358
|
+
const routeKey = String(resolveTemplate(condStr, context));
|
|
359
|
+
const routes = node.routes || {};
|
|
360
|
+
let nextNode = routes[routeKey];
|
|
361
|
+
if (!nextNode && node.default) {
|
|
362
|
+
nextNode = typeof node.default === 'string' && routes[node.default]
|
|
363
|
+
? routes[node.default]
|
|
364
|
+
: node.default;
|
|
365
|
+
}
|
|
366
|
+
result = { next: nextNode || null, outputs: { routeKey } };
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case 'team':
|
|
371
|
+
// Deprecated team nodes — skip
|
|
372
|
+
result = { next: node.next || null, outputs: {} };
|
|
373
|
+
break;
|
|
374
|
+
|
|
375
|
+
default:
|
|
376
|
+
result = { next: node.next || null, outputs: {} };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Store and advance
|
|
380
|
+
nodesVisited.push(node.id);
|
|
381
|
+
context.nodes[node.id] = { outputs: result.outputs || {} };
|
|
382
|
+
currentNodeId = result.next;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Actionable node — build instruction and stop
|
|
387
|
+
let instruction;
|
|
388
|
+
const nodeContext = detectNodeContext(node.id);
|
|
389
|
+
|
|
390
|
+
switch (node.type) {
|
|
391
|
+
case 'agent': {
|
|
392
|
+
const resolvedInputs = node.inputs
|
|
393
|
+
? resolveTemplate(node.inputs, context)
|
|
394
|
+
: {};
|
|
395
|
+
instruction = protocol.buildAgentInstruction(
|
|
396
|
+
node.id, node.agent, node.config, resolvedInputs
|
|
397
|
+
);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case 'parallel': {
|
|
402
|
+
const resolvedBranches = (node.branches || []).map(b => ({
|
|
403
|
+
...b,
|
|
404
|
+
inputs: b.inputs ? resolveTemplate(b.inputs, context) : {}
|
|
405
|
+
}));
|
|
406
|
+
instruction = protocol.buildParallelInstruction(
|
|
407
|
+
node.id, resolvedBranches,
|
|
408
|
+
{ nodeContext, taskSlug: context.inputs && context.inputs.task }
|
|
409
|
+
);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case 'interrupt': {
|
|
414
|
+
const resolvedDisplay = node.display
|
|
415
|
+
? resolveTemplate(node.display, context)
|
|
416
|
+
: { title: node.reason || 'Interrupt', options: [] };
|
|
417
|
+
instruction = protocol.buildInterruptInstruction(
|
|
418
|
+
node.id, resolvedDisplay, node.routes, node.reason
|
|
419
|
+
);
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
case 'sequence': {
|
|
424
|
+
instruction = protocol.buildSequenceInstruction(node.id, node.steps);
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
default:
|
|
429
|
+
instruction = { type: node.type, nodeId: node.id };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Save checkpoint at this actionable node
|
|
433
|
+
cpManager.save(flowId, {
|
|
434
|
+
currentNode: node.id,
|
|
435
|
+
nextNode: currentNodeId,
|
|
436
|
+
context: serializeContext(context),
|
|
437
|
+
nodesVisited: [...nodesVisited]
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
instruction,
|
|
442
|
+
nodeId: node.id,
|
|
443
|
+
nodeType: node.type,
|
|
444
|
+
description: node.description || ''
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Reached end without actionable node
|
|
449
|
+
return { finished: true, nodesVisited };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* complete: Apply result to current node, advance checkpoint.
|
|
454
|
+
*/
|
|
455
|
+
function cmdComplete(args) {
|
|
456
|
+
const flowPath = args.flow;
|
|
457
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
458
|
+
|
|
459
|
+
const resultStr = args.result;
|
|
460
|
+
if (!resultStr) return { error: 'Missing --result <json>' };
|
|
461
|
+
|
|
462
|
+
let result;
|
|
463
|
+
try {
|
|
464
|
+
result = JSON.parse(resultStr);
|
|
465
|
+
} catch {
|
|
466
|
+
return { error: 'Invalid JSON in --result' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
470
|
+
const engine = new FlowEngine();
|
|
471
|
+
engine.loadFlow(flowJson);
|
|
472
|
+
|
|
473
|
+
const flowId = flowIdFromPath(flowPath);
|
|
474
|
+
const cpManager = new CheckpointManager();
|
|
475
|
+
const checkpoint = cpManager.load(flowId);
|
|
476
|
+
|
|
477
|
+
if (!checkpoint) {
|
|
478
|
+
return { error: 'No checkpoint found. Run init first.' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const context = deserializeContext(checkpoint.context);
|
|
482
|
+
const nodesVisited = checkpoint.nodesVisited || [];
|
|
483
|
+
const currentNodeId = checkpoint.nextNode || checkpoint.currentNode;
|
|
484
|
+
|
|
485
|
+
if (!currentNodeId) {
|
|
486
|
+
return { error: 'No current node to complete.' };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Guard against double-completion: after cmdStep, nextNode === currentNode.
|
|
490
|
+
// After cmdComplete, nextNode advances past currentNode. If we see them
|
|
491
|
+
// differ, this checkpoint was already completed — need a step first.
|
|
492
|
+
if (checkpoint.nextNode && checkpoint.nextNode !== checkpoint.currentNode) {
|
|
493
|
+
return { error: `Node "${checkpoint.currentNode}" was already completed. Run step first.` };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const node = engine.graph.nodes.get(currentNodeId);
|
|
497
|
+
if (!node) {
|
|
498
|
+
return { error: `Node "${currentNodeId}" not found` };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Store result
|
|
502
|
+
nodesVisited.push(node.id);
|
|
503
|
+
context.nodes[node.id] = { outputs: result };
|
|
504
|
+
|
|
505
|
+
// Determine next node
|
|
506
|
+
let nextNode = node.next || null;
|
|
507
|
+
|
|
508
|
+
// Handle maxIterations for parallel nodes
|
|
509
|
+
if (node.type === 'parallel' && node.maxIterations) {
|
|
510
|
+
context._iterations = context._iterations || {};
|
|
511
|
+
context._iterations[node.id] = (context._iterations[node.id] || 0) + 1;
|
|
512
|
+
if (context._iterations[node.id] >= node.maxIterations && node.onMaxIterations) {
|
|
513
|
+
nextNode = node.onMaxIterations;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
cpManager.save(flowId, {
|
|
518
|
+
currentNode: node.id,
|
|
519
|
+
nextNode,
|
|
520
|
+
context: serializeContext(context),
|
|
521
|
+
nodesVisited: [...nodesVisited]
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
completed: true,
|
|
526
|
+
nodeId: node.id,
|
|
527
|
+
nextNode
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* status: Show current state of an active session.
|
|
533
|
+
*/
|
|
534
|
+
function cmdStatus(args) {
|
|
535
|
+
const flowPath = args.flow;
|
|
536
|
+
if (!flowPath) return { error: 'Missing --flow <path>' };
|
|
537
|
+
|
|
538
|
+
const flowId = flowIdFromPath(flowPath);
|
|
539
|
+
const cpManager = new CheckpointManager();
|
|
540
|
+
const checkpoint = cpManager.load(flowId);
|
|
541
|
+
|
|
542
|
+
if (!checkpoint) {
|
|
543
|
+
return { active: false, message: 'No active session.' };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
active: true,
|
|
548
|
+
flowId: checkpoint.flowId || flowId,
|
|
549
|
+
currentNode: checkpoint.currentNode,
|
|
550
|
+
nextNode: checkpoint.nextNode,
|
|
551
|
+
nodesVisited: checkpoint.nodesVisited || [],
|
|
552
|
+
visitedCount: (checkpoint.nodesVisited || []).length,
|
|
553
|
+
createdAt: checkpoint.createdAt || null
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---------- Main dispatcher ----------
|
|
558
|
+
|
|
559
|
+
const COMMANDS = {
|
|
560
|
+
validate: cmdValidate,
|
|
561
|
+
'dry-run': cmdDryRun,
|
|
562
|
+
init: cmdInit,
|
|
563
|
+
step: cmdStep,
|
|
564
|
+
complete: cmdComplete,
|
|
565
|
+
status: cmdStatus
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
async function main(argv) {
|
|
569
|
+
const { command, args } = parseArgs(argv || process.argv);
|
|
570
|
+
|
|
571
|
+
if (!command || command === 'help') {
|
|
572
|
+
return {
|
|
573
|
+
usage: 'elsabro-flow <command> [options]',
|
|
574
|
+
commands: Object.keys(COMMANDS)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const handler = COMMANDS[command];
|
|
579
|
+
if (!handler) {
|
|
580
|
+
return { error: `Unknown command: "${command}". Available: ${Object.keys(COMMANDS).join(', ')}` };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return handler(args);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Run if invoked directly
|
|
587
|
+
if (require.main === module) {
|
|
588
|
+
main().then(result => {
|
|
589
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
590
|
+
if (result.error) process.exitCode = 1;
|
|
591
|
+
}).catch(err => {
|
|
592
|
+
process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + '\n');
|
|
593
|
+
process.exitCode = 1;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
module.exports = { main, parseArgs, flowIdFromPath, isAutoResolvable, detectNodeContext, COMMANDS };
|
|
@@ -33,12 +33,24 @@ class ExecutorError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
class DeprecatedNodeError extends Error {
|
|
37
|
+
constructor(nodeId, reason) {
|
|
38
|
+
super(`Node "${nodeId}" is deprecated: ${reason}`);
|
|
39
|
+
this.name = 'DeprecatedNodeError';
|
|
40
|
+
this.nodeId = nodeId;
|
|
41
|
+
this.reason = reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
// ---------- Runtime status guard ----------
|
|
37
46
|
|
|
38
47
|
function checkRuntimeStatus(node) {
|
|
39
48
|
if (node.runtime_status === 'not_implemented') {
|
|
40
49
|
throw new NotImplementedError(node.id, node.gaps);
|
|
41
50
|
}
|
|
51
|
+
if (node.runtime_status === 'deprecated') {
|
|
52
|
+
throw new DeprecatedNodeError(node.id, node.deprecated_reason || 'Node is deprecated');
|
|
53
|
+
}
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
// ---------- Simple Executors ----------
|
|
@@ -402,5 +414,6 @@ module.exports = {
|
|
|
402
414
|
getExecutor,
|
|
403
415
|
checkRuntimeStatus,
|
|
404
416
|
NotImplementedError,
|
|
405
|
-
ExecutorError
|
|
417
|
+
ExecutorError,
|
|
418
|
+
DeprecatedNodeError
|
|
406
419
|
};
|
package/flow-engine/src/index.js
CHANGED
|
@@ -15,7 +15,9 @@ const { buildGraph, validateGraph, getNode, GraphError } = require('./graph');
|
|
|
15
15
|
const { resolveTemplate, resolveExpression, TemplateError } = require('./template');
|
|
16
16
|
const { runFlow, serializeContext, deserializeContext } = require('./runner');
|
|
17
17
|
const { CheckpointManager } = require('./checkpoint');
|
|
18
|
-
const { NotImplementedError } = require('./executors');
|
|
18
|
+
const { NotImplementedError, DeprecatedNodeError } = require('./executors');
|
|
19
|
+
const { PartyEngine } = require('./party');
|
|
20
|
+
const { CallbackProtocol } = require('./callbacks');
|
|
19
21
|
|
|
20
22
|
class FlowEngine {
|
|
21
23
|
/**
|
|
@@ -134,4 +136,4 @@ class FlowEngine {
|
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
module.exports = { FlowEngine, GraphError, TemplateError, NotImplementedError, CheckpointManager };
|
|
139
|
+
module.exports = { FlowEngine, GraphError, TemplateError, NotImplementedError, DeprecatedNodeError, CheckpointManager, PartyEngine, CallbackProtocol };
|