keystone-cli 0.5.0 → 0.6.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/README.md +55 -8
- package/package.json +5 -3
- package/src/cli.ts +33 -192
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +122 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +84 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +74 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +27 -14
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +91 -6
- package/src/runner/llm-executor.ts +26 -6
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +46 -1
- package/src/runner/step-executor.ts +154 -60
- package/src/runner/stream-utils.test.ts +65 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +436 -251
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- package/src/utils/redactor.ts +48 -13
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, test } from 'bun:test';
|
|
2
|
+
import type { Step, Workflow } from '../parser/schema';
|
|
3
|
+
import * as StepExecutor from './step-executor';
|
|
4
|
+
import { WorkflowRunner } from './workflow-runner';
|
|
5
|
+
|
|
6
|
+
describe('WorkflowRunner Auto-Heal', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.fn();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('should attempt to auto-heal a failing step', async () => {
|
|
12
|
+
const workflow: Workflow = {
|
|
13
|
+
name: 'auto-heal-test',
|
|
14
|
+
steps: [
|
|
15
|
+
{
|
|
16
|
+
id: 'fail-step',
|
|
17
|
+
type: 'shell',
|
|
18
|
+
run: 'exit 1',
|
|
19
|
+
auto_heal: {
|
|
20
|
+
agent: 'fixer-agent',
|
|
21
|
+
maxAttempts: 1,
|
|
22
|
+
},
|
|
23
|
+
} as Step,
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const runner = new WorkflowRunner(workflow, {
|
|
28
|
+
logger: { log: () => {}, error: () => {}, warn: () => {} },
|
|
29
|
+
dbPath: ':memory:',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
|
|
33
|
+
const db = (runner as any).db;
|
|
34
|
+
await db.createRun(runner.getRunId(), workflow.name, {});
|
|
35
|
+
|
|
36
|
+
const spy = jest.spyOn(StepExecutor, 'executeStep');
|
|
37
|
+
|
|
38
|
+
spy.mockImplementation(async (step, _context) => {
|
|
39
|
+
if (step.id === 'fail-step-healer') {
|
|
40
|
+
return {
|
|
41
|
+
status: 'success',
|
|
42
|
+
output: { run: 'echo "fixed"' },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (step.id === 'fail-step') {
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accessing run property dynamically
|
|
48
|
+
if ((step as any).run === 'echo "fixed"') {
|
|
49
|
+
return { status: 'success', output: 'fixed' };
|
|
50
|
+
}
|
|
51
|
+
return { status: 'failed', output: null, error: 'Command failed' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { status: 'failed', output: null, error: 'Unknown step' };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
|
|
58
|
+
await (runner as any).executeStepWithForeach(workflow.steps[0]);
|
|
59
|
+
|
|
60
|
+
expect(spy).toHaveBeenCalledTimes(3);
|
|
61
|
+
|
|
62
|
+
spy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
4
|
+
import type { Step } from '../parser/schema.ts';
|
|
5
|
+
import { DebugRepl } from './debug-repl.ts';
|
|
6
|
+
|
|
7
|
+
describe('DebugRepl', () => {
|
|
8
|
+
const mockContext: ExpressionContext = { inputs: { foo: 'bar' } };
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock step typing
|
|
10
|
+
const mockStep: Step = { id: 'test-step', type: 'shell', run: 'echo "fail"' } as any;
|
|
11
|
+
const mockError = new Error('Test Error');
|
|
12
|
+
|
|
13
|
+
test('should resolve with "skip" when user types "skip"', async () => {
|
|
14
|
+
const input = new PassThrough();
|
|
15
|
+
const output = new PassThrough();
|
|
16
|
+
const mockLogger = { log: () => {}, error: () => {}, warn: () => {} };
|
|
17
|
+
const repl = new DebugRepl(mockContext, mockStep, mockError, mockLogger, input, output);
|
|
18
|
+
|
|
19
|
+
const promise = repl.start();
|
|
20
|
+
|
|
21
|
+
// Wait a tick for prompt
|
|
22
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
23
|
+
|
|
24
|
+
input.write('skip\n');
|
|
25
|
+
|
|
26
|
+
const result = await promise;
|
|
27
|
+
expect(result).toEqual({ type: 'skip' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('should resolve with "retry" when user types "retry"', async () => {
|
|
31
|
+
const input = new PassThrough();
|
|
32
|
+
const output = new PassThrough();
|
|
33
|
+
const mockLogger = { log: () => {}, error: () => {}, warn: () => {} };
|
|
34
|
+
const repl = new DebugRepl(mockContext, mockStep, mockError, mockLogger, input, output);
|
|
35
|
+
|
|
36
|
+
const promise = repl.start();
|
|
37
|
+
|
|
38
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
39
|
+
input.write('retry\n');
|
|
40
|
+
|
|
41
|
+
const result = await promise;
|
|
42
|
+
expect(result.type).toBe('retry');
|
|
43
|
+
if (result.type === 'retry') {
|
|
44
|
+
expect(result.modifiedStep).toBe(mockStep);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('should resolve with "continue_failure" when user types "exit"', async () => {
|
|
49
|
+
const input = new PassThrough();
|
|
50
|
+
const output = new PassThrough();
|
|
51
|
+
const mockLogger = { log: () => {}, error: () => {}, warn: () => {} };
|
|
52
|
+
const repl = new DebugRepl(mockContext, mockStep, mockError, mockLogger, input, output);
|
|
53
|
+
|
|
54
|
+
const promise = repl.start();
|
|
55
|
+
|
|
56
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
57
|
+
input.write('exit\n');
|
|
58
|
+
|
|
59
|
+
const result = await promise;
|
|
60
|
+
expect(result).toEqual({ type: 'continue_failure' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('should parse shell commands correctly', () => {
|
|
64
|
+
// We import the function dynamically to test it, or we assume it's exported
|
|
65
|
+
const { parseShellCommand } = require('./debug-repl.ts');
|
|
66
|
+
|
|
67
|
+
expect(parseShellCommand('code')).toEqual(['code']);
|
|
68
|
+
expect(parseShellCommand('code --wait')).toEqual(['code', '--wait']);
|
|
69
|
+
expect(parseShellCommand('code --wait "some file"')).toEqual(['code', '--wait', 'some file']);
|
|
70
|
+
expect(parseShellCommand("vim 'my file'")).toEqual(['vim', 'my file']);
|
|
71
|
+
expect(parseShellCommand('editor -a -b -c')).toEqual(['editor', '-a', '-b', '-c']);
|
|
72
|
+
expect(parseShellCommand(' spaced command ')).toEqual(['spaced', 'command']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as readline from 'node:readline';
|
|
6
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
|
+
import { type ExpressionContext, ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
8
|
+
import type { Step } from '../parser/schema.ts';
|
|
9
|
+
import { extractJson } from '../utils/json-parser.ts';
|
|
10
|
+
|
|
11
|
+
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
12
|
+
|
|
13
|
+
export type DebugAction =
|
|
14
|
+
| { type: 'retry'; modifiedStep?: Step }
|
|
15
|
+
| { type: 'skip' }
|
|
16
|
+
| { type: 'continue_failure' }; // Default behavior (exit debug mode, let it fail)
|
|
17
|
+
|
|
18
|
+
export class DebugRepl {
|
|
19
|
+
constructor(
|
|
20
|
+
private context: ExpressionContext,
|
|
21
|
+
private step: Step,
|
|
22
|
+
private error: unknown,
|
|
23
|
+
private logger: Logger = new ConsoleLogger(),
|
|
24
|
+
private inputStream: NodeJS.ReadableStream = process.stdin,
|
|
25
|
+
private outputStream: NodeJS.WritableStream = process.stdout
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
public async start(): Promise<DebugAction> {
|
|
29
|
+
this.logger.error(`\n❌ Step '${this.step.id}' failed.`);
|
|
30
|
+
this.logger.error(
|
|
31
|
+
` Error: ${this.error instanceof Error ? this.error.message : String(this.error)}`
|
|
32
|
+
);
|
|
33
|
+
this.logger.log('\nEntering Debug Mode. Available commands:');
|
|
34
|
+
this.logger.log(' > context (view current inputs/outputs involved in this step)');
|
|
35
|
+
this.logger.log(' > retry (re-run step, optionally with edited definition)');
|
|
36
|
+
this.logger.log(' > edit (edit the step definition in your $EDITOR)');
|
|
37
|
+
this.logger.log(' > skip (skip this step and proceed)');
|
|
38
|
+
this.logger.log(' > eval <code> (run JS expression against context)');
|
|
39
|
+
this.logger.log(' > exit (resume failure/exit)');
|
|
40
|
+
|
|
41
|
+
const rl = readline.createInterface({
|
|
42
|
+
input: this.inputStream,
|
|
43
|
+
output: this.outputStream,
|
|
44
|
+
prompt: 'debug> ',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
rl.prompt();
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
rl.on('line', (line) => {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
const [cmd, ...args] = trimmed.split(' ');
|
|
53
|
+
const argStr = args.join(' ');
|
|
54
|
+
|
|
55
|
+
switch (cmd) {
|
|
56
|
+
case 'context':
|
|
57
|
+
// Show meaningful context context
|
|
58
|
+
this.logger.log(JSON.stringify(this.context, null, 2));
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case 'retry':
|
|
62
|
+
rl.close();
|
|
63
|
+
resolve({ type: 'retry', modifiedStep: this.step });
|
|
64
|
+
break;
|
|
65
|
+
|
|
66
|
+
case 'skip':
|
|
67
|
+
rl.close();
|
|
68
|
+
resolve({ type: 'skip' });
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case 'exit':
|
|
72
|
+
case 'quit':
|
|
73
|
+
rl.close();
|
|
74
|
+
resolve({ type: 'continue_failure' });
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case 'edit': {
|
|
78
|
+
try {
|
|
79
|
+
const newStep = this.editStep(this.step);
|
|
80
|
+
if (newStep) {
|
|
81
|
+
this.step = newStep;
|
|
82
|
+
this.logger.log('✓ Step definition updated in memory. Type "retry" to run it.');
|
|
83
|
+
} else {
|
|
84
|
+
this.logger.log('No changes made.');
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
this.logger.error(`Error editing step: ${e}`);
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'eval':
|
|
93
|
+
try {
|
|
94
|
+
if (!argStr) {
|
|
95
|
+
this.logger.log('Usage: eval <expression>');
|
|
96
|
+
} else {
|
|
97
|
+
const result = ExpressionEvaluator.evaluateExpression(argStr, this.context);
|
|
98
|
+
this.logger.log(String(result));
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
this.logger.error(`Eval error: ${e instanceof Error ? e.message : String(e)}`);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
|
|
105
|
+
case '':
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
default:
|
|
109
|
+
this.logger.log(`Unknown command: ${cmd}`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (cmd !== 'retry' && cmd !== 'skip' && cmd !== 'exit' && cmd !== 'quit') {
|
|
114
|
+
rl.prompt();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private editStep(step: Step): Step | null {
|
|
121
|
+
const editorEnv = process.env.EDITOR || 'vim'; // Default to vim if not set
|
|
122
|
+
// Validate editor name to prevent shell injection (allow alphanumeric, dash, underscore, slash, and spaces for args)
|
|
123
|
+
// We strictly block semicolon, pipe, ampersand, backtick, $ to prevent command injection
|
|
124
|
+
const safeEditor = /^[\w./\s-]+$/.test(editorEnv) ? editorEnv : 'vi';
|
|
125
|
+
if (safeEditor !== editorEnv) {
|
|
126
|
+
this.logger.warn(
|
|
127
|
+
`Warning: $EDITOR value "${editorEnv}" contains unsafe characters. Falling back to "vi".`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// Sanitize step ID to prevent path traversal
|
|
131
|
+
const sanitizedId = step.id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
132
|
+
const tempFile = path.join(os.tmpdir(), `keystone-step-${sanitizedId}-${Date.now()}.json`);
|
|
133
|
+
|
|
134
|
+
// Write step to temp file
|
|
135
|
+
fs.writeFileSync(tempFile, JSON.stringify(step, null, 2));
|
|
136
|
+
|
|
137
|
+
// Spawn editor
|
|
138
|
+
try {
|
|
139
|
+
// Parse editor string into command and args (e.g. "code --wait", "subl -w")
|
|
140
|
+
const [editorCmd, ...editorArgs] = parseShellCommand(safeEditor);
|
|
141
|
+
|
|
142
|
+
// Use stdio: 'inherit' to let the editor take over the terminal
|
|
143
|
+
// Note: shell: false for security - prevents injection via $EDITOR
|
|
144
|
+
const result = spawnSync(editorCmd, [...editorArgs, tempFile], {
|
|
145
|
+
stdio: 'inherit',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (result.error) {
|
|
149
|
+
throw result.error;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Read back
|
|
153
|
+
const content = fs.readFileSync(tempFile, 'utf-8');
|
|
154
|
+
|
|
155
|
+
// Parse JSON
|
|
156
|
+
// We use our safe extractor helper or just JSON.parse
|
|
157
|
+
try {
|
|
158
|
+
const newStep = JSON.parse(content);
|
|
159
|
+
// Basic validation: must have id and type
|
|
160
|
+
if (!newStep.id || !newStep.type) {
|
|
161
|
+
this.logger.error('Invalid step definition: missing id or type');
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return newStep as Step;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
this.logger.error('Failed to parse JSON from editor. Changes discarded.');
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
if (fs.existsSync(tempFile)) {
|
|
171
|
+
fs.unlinkSync(tempFile);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parses a shell command string into arguments, respecting quotes.
|
|
179
|
+
* Handles single quotes and double quotes.
|
|
180
|
+
* Example: 'code --wait' -> ['code', '--wait']
|
|
181
|
+
* Example: 'my-editor "some arg"' -> ['my-editor', 'some arg']
|
|
182
|
+
*/
|
|
183
|
+
export function parseShellCommand(command: string): string[] {
|
|
184
|
+
const args: string[] = [];
|
|
185
|
+
let currentArg = '';
|
|
186
|
+
let inDoubleQuote = false;
|
|
187
|
+
let inSingleQuote = false;
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < command.length; i++) {
|
|
190
|
+
const char = command[i];
|
|
191
|
+
|
|
192
|
+
if (inDoubleQuote) {
|
|
193
|
+
if (char === '"') {
|
|
194
|
+
inDoubleQuote = false;
|
|
195
|
+
} else {
|
|
196
|
+
currentArg += char;
|
|
197
|
+
}
|
|
198
|
+
} else if (inSingleQuote) {
|
|
199
|
+
if (char === "'") {
|
|
200
|
+
inSingleQuote = false;
|
|
201
|
+
} else {
|
|
202
|
+
currentArg += char;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (char === '"') {
|
|
206
|
+
inDoubleQuote = true;
|
|
207
|
+
} else if (char === "'") {
|
|
208
|
+
inSingleQuote = true;
|
|
209
|
+
} else if (char === ' ') {
|
|
210
|
+
if (currentArg.length > 0) {
|
|
211
|
+
args.push(currentArg);
|
|
212
|
+
currentArg = '';
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
currentArg += char;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (currentArg.length > 0) {
|
|
221
|
+
args.push(currentArg);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return args;
|
|
225
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { WorkflowDb } from '../db/workflow-db.ts';
|
|
3
|
+
import { type ExpressionContext, ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
4
|
+
import type { Step } from '../parser/schema.ts';
|
|
5
|
+
import { StepStatus, WorkflowStatus } from '../types/status.ts';
|
|
6
|
+
import type { Logger } from '../utils/logger.ts';
|
|
7
|
+
import { WorkflowSuspendedError } from './step-executor.ts';
|
|
8
|
+
import type { ForeachStepContext, StepContext } from './workflow-runner.ts';
|
|
9
|
+
|
|
10
|
+
export type ExecuteStepCallback = (
|
|
11
|
+
step: Step,
|
|
12
|
+
context: ExpressionContext,
|
|
13
|
+
stepExecId: string
|
|
14
|
+
) => Promise<StepContext>;
|
|
15
|
+
|
|
16
|
+
export class ForeachExecutor {
|
|
17
|
+
private static readonly MEMORY_WARNING_THRESHOLD = 1000;
|
|
18
|
+
private hasWarnedMemory = false;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private db: WorkflowDb,
|
|
22
|
+
private logger: Logger,
|
|
23
|
+
private executeStepFn: ExecuteStepCallback
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Aggregate outputs from multiple iterations of a foreach step
|
|
28
|
+
*/
|
|
29
|
+
public static aggregateOutputs(outputs: unknown[]): Record<string, unknown> {
|
|
30
|
+
const parentOutputs: Record<string, unknown> = {};
|
|
31
|
+
|
|
32
|
+
const validOutputs = outputs.filter((o) => o !== undefined);
|
|
33
|
+
if (validOutputs.length === 0) return parentOutputs;
|
|
34
|
+
|
|
35
|
+
// We can only aggregate objects, and we assume all outputs have similar shape
|
|
36
|
+
const firstOutput = validOutputs[0];
|
|
37
|
+
if (typeof firstOutput !== 'object' || firstOutput === null) {
|
|
38
|
+
return parentOutputs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Collect all keys from all outputs
|
|
42
|
+
const keys = new Set<string>();
|
|
43
|
+
for (const output of validOutputs) {
|
|
44
|
+
if (typeof output === 'object' && output !== null) {
|
|
45
|
+
for (const key of Object.keys(output)) {
|
|
46
|
+
keys.add(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// For each key, create an array of values
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
parentOutputs[key] = outputs.map((output) => {
|
|
54
|
+
if (typeof output === 'object' && output !== null) {
|
|
55
|
+
return (output as Record<string, unknown>)[key];
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return parentOutputs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Execute a step with foreach logic
|
|
66
|
+
*/
|
|
67
|
+
async execute(
|
|
68
|
+
step: Step,
|
|
69
|
+
baseContext: ExpressionContext,
|
|
70
|
+
runId: string,
|
|
71
|
+
existingContext?: ForeachStepContext
|
|
72
|
+
): Promise<ForeachStepContext> {
|
|
73
|
+
if (!step.foreach) {
|
|
74
|
+
throw new Error('Step is not a foreach step');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const items = ExpressionEvaluator.evaluate(step.foreach, baseContext);
|
|
78
|
+
if (!Array.isArray(items)) {
|
|
79
|
+
throw new Error(`foreach expression must evaluate to an array: ${step.foreach}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.logger.log(` ⤷ Executing step ${step.id} for ${items.length} items`);
|
|
83
|
+
|
|
84
|
+
if (items.length > ForeachExecutor.MEMORY_WARNING_THRESHOLD && !this.hasWarnedMemory) {
|
|
85
|
+
this.logger.warn(
|
|
86
|
+
` ⚠️ Warning: Large foreach loop detected (${items.length} items). This may consume significant memory and lead to instability.`
|
|
87
|
+
);
|
|
88
|
+
this.hasWarnedMemory = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Evaluate concurrency
|
|
92
|
+
let concurrencyLimit = items.length;
|
|
93
|
+
if (step.concurrency !== undefined) {
|
|
94
|
+
if (typeof step.concurrency === 'string') {
|
|
95
|
+
concurrencyLimit = Number(ExpressionEvaluator.evaluate(step.concurrency, baseContext));
|
|
96
|
+
if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`concurrency must evaluate to a positive integer, got: ${concurrencyLimit}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
concurrencyLimit = step.concurrency;
|
|
103
|
+
if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
|
|
104
|
+
throw new Error(`concurrency must be a positive integer, got: ${concurrencyLimit}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Create parent step record in DB
|
|
110
|
+
const parentStepExecId = randomUUID();
|
|
111
|
+
await this.db.createStep(parentStepExecId, runId, step.id);
|
|
112
|
+
await this.db.startStep(parentStepExecId);
|
|
113
|
+
|
|
114
|
+
// Persist the foreach items
|
|
115
|
+
await this.db.completeStep(parentStepExecId, StepStatus.PENDING, { __foreachItems: items });
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Initialize results array
|
|
119
|
+
const itemResults: StepContext[] = existingContext?.items || new Array(items.length);
|
|
120
|
+
const shouldCheckDb = !!existingContext;
|
|
121
|
+
|
|
122
|
+
// Ensure array is correct length
|
|
123
|
+
if (itemResults.length !== items.length) {
|
|
124
|
+
itemResults.length = items.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Worker pool implementation
|
|
128
|
+
let currentIndex = 0;
|
|
129
|
+
let aborted = false;
|
|
130
|
+
const workers = new Array(Math.min(concurrencyLimit, items.length))
|
|
131
|
+
.fill(null)
|
|
132
|
+
.map(async () => {
|
|
133
|
+
const nextIndex = () => {
|
|
134
|
+
if (aborted) return null;
|
|
135
|
+
if (currentIndex >= items.length) return null;
|
|
136
|
+
const i = currentIndex;
|
|
137
|
+
currentIndex += 1;
|
|
138
|
+
return i;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
while (true) {
|
|
142
|
+
const i = nextIndex();
|
|
143
|
+
if (i === null) break;
|
|
144
|
+
|
|
145
|
+
if (aborted) break;
|
|
146
|
+
|
|
147
|
+
const item = items[i];
|
|
148
|
+
|
|
149
|
+
// Skip if already successful or skipped
|
|
150
|
+
if (
|
|
151
|
+
itemResults[i] &&
|
|
152
|
+
(itemResults[i].status === StepStatus.SUCCESS ||
|
|
153
|
+
itemResults[i].status === StepStatus.SKIPPED)
|
|
154
|
+
) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build item-specific context
|
|
159
|
+
const itemContext = {
|
|
160
|
+
...baseContext,
|
|
161
|
+
item,
|
|
162
|
+
index: i,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Check DB again for robustness (resume flows only)
|
|
166
|
+
const existingExec = shouldCheckDb
|
|
167
|
+
? await this.db.getStepByIteration(runId, step.id, i)
|
|
168
|
+
: undefined;
|
|
169
|
+
if (
|
|
170
|
+
existingExec &&
|
|
171
|
+
(existingExec.status === StepStatus.SUCCESS ||
|
|
172
|
+
existingExec.status === StepStatus.SKIPPED)
|
|
173
|
+
) {
|
|
174
|
+
let output: unknown = null;
|
|
175
|
+
let itemStatus = existingExec.status as
|
|
176
|
+
| typeof StepStatus.SUCCESS
|
|
177
|
+
| typeof StepStatus.SKIPPED
|
|
178
|
+
| typeof StepStatus.FAILED;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
output = existingExec.output ? JSON.parse(existingExec.output) : null;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
this.logger.warn(
|
|
184
|
+
`Failed to parse output for step ${step.id} iteration ${i}: ${error}`
|
|
185
|
+
);
|
|
186
|
+
output = { error: 'Failed to parse output' };
|
|
187
|
+
itemStatus = StepStatus.FAILED;
|
|
188
|
+
aborted = true; // Fail fast if we find corrupted data
|
|
189
|
+
try {
|
|
190
|
+
await this.db.completeStep(
|
|
191
|
+
existingExec.id,
|
|
192
|
+
StepStatus.FAILED,
|
|
193
|
+
output,
|
|
194
|
+
'Failed to parse output'
|
|
195
|
+
);
|
|
196
|
+
} catch (dbError) {
|
|
197
|
+
this.logger.warn(
|
|
198
|
+
`Failed to update DB for corrupted output on step ${step.id} iteration ${i}: ${dbError}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
itemResults[i] = {
|
|
203
|
+
output,
|
|
204
|
+
outputs:
|
|
205
|
+
typeof output === 'object' && output !== null && !Array.isArray(output)
|
|
206
|
+
? (output as Record<string, unknown>)
|
|
207
|
+
: {},
|
|
208
|
+
status: itemStatus,
|
|
209
|
+
} as StepContext;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (aborted) break;
|
|
214
|
+
|
|
215
|
+
const stepExecId = randomUUID();
|
|
216
|
+
await this.db.createStep(stepExecId, runId, step.id, i);
|
|
217
|
+
|
|
218
|
+
// Execute and store result
|
|
219
|
+
try {
|
|
220
|
+
if (aborted) break;
|
|
221
|
+
this.logger.log(` ⤷ [${i + 1}/${items.length}] Executing iteration...`);
|
|
222
|
+
itemResults[i] = await this.executeStepFn(step, itemContext, stepExecId);
|
|
223
|
+
if (
|
|
224
|
+
itemResults[i].status === StepStatus.FAILED ||
|
|
225
|
+
itemResults[i].status === StepStatus.SUSPENDED
|
|
226
|
+
) {
|
|
227
|
+
aborted = true;
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
aborted = true;
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const workerResults = await Promise.allSettled(workers);
|
|
237
|
+
|
|
238
|
+
// Check if any worker rejected (this would be due to an unexpected throw)
|
|
239
|
+
const firstError = workerResults.find((r) => r.status === 'rejected') as
|
|
240
|
+
| PromiseRejectedResult
|
|
241
|
+
| undefined;
|
|
242
|
+
if (firstError) {
|
|
243
|
+
throw firstError.reason;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Aggregate results
|
|
247
|
+
const outputs = itemResults.map((r) => r?.output);
|
|
248
|
+
const allSuccess = itemResults.every((r) => r?.status === StepStatus.SUCCESS);
|
|
249
|
+
const anyFailed = itemResults.some((r) => r?.status === StepStatus.FAILED);
|
|
250
|
+
const anySuspended = itemResults.some((r) => r?.status === StepStatus.SUSPENDED);
|
|
251
|
+
|
|
252
|
+
// Aggregate usage
|
|
253
|
+
const aggregatedUsage = itemResults.reduce(
|
|
254
|
+
(acc, r) => {
|
|
255
|
+
if (r?.usage) {
|
|
256
|
+
acc.prompt_tokens += r.usage.prompt_tokens;
|
|
257
|
+
acc.completion_tokens += r.usage.completion_tokens;
|
|
258
|
+
acc.total_tokens += r.usage.total_tokens;
|
|
259
|
+
}
|
|
260
|
+
return acc;
|
|
261
|
+
},
|
|
262
|
+
{ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Map child properties
|
|
266
|
+
const mappedOutputs = ForeachExecutor.aggregateOutputs(outputs);
|
|
267
|
+
|
|
268
|
+
// Determine final status
|
|
269
|
+
let finalStatus: (typeof StepStatus)[keyof typeof StepStatus] = StepStatus.FAILED;
|
|
270
|
+
if (allSuccess) {
|
|
271
|
+
finalStatus = StepStatus.SUCCESS;
|
|
272
|
+
} else if (anyFailed) {
|
|
273
|
+
finalStatus = StepStatus.FAILED;
|
|
274
|
+
} else if (anySuspended) {
|
|
275
|
+
finalStatus = StepStatus.SUSPENDED;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const aggregatedContext: ForeachStepContext = {
|
|
279
|
+
output: outputs,
|
|
280
|
+
outputs: mappedOutputs,
|
|
281
|
+
status: finalStatus,
|
|
282
|
+
items: itemResults,
|
|
283
|
+
usage: aggregatedUsage,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const persistedContext = {
|
|
287
|
+
...aggregatedContext,
|
|
288
|
+
__foreachItems: items,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Update parent step record
|
|
292
|
+
await this.db.completeStep(
|
|
293
|
+
parentStepExecId,
|
|
294
|
+
finalStatus,
|
|
295
|
+
persistedContext,
|
|
296
|
+
finalStatus === StepStatus.FAILED ? 'One or more iterations failed' : undefined
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (finalStatus === StepStatus.SUSPENDED) {
|
|
300
|
+
const suspendedItem = itemResults.find((r) => r.status === StepStatus.SUSPENDED);
|
|
301
|
+
throw new WorkflowSuspendedError(
|
|
302
|
+
suspendedItem?.error || 'Iteration suspended',
|
|
303
|
+
step.id,
|
|
304
|
+
'text'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (finalStatus === StepStatus.FAILED) {
|
|
309
|
+
throw new Error(`Step ${step.id} failed: one or more iterations failed`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return aggregatedContext;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error instanceof WorkflowSuspendedError) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
// Mark parent step as failed (if not already handled)
|
|
318
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
319
|
+
try {
|
|
320
|
+
await this.db.completeStep(parentStepExecId, StepStatus.FAILED, null, errorMsg);
|
|
321
|
+
} catch (dbError) {
|
|
322
|
+
this.logger.error(`Failed to update DB on foreach error: ${dbError}`);
|
|
323
|
+
}
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|