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.
Files changed (47) hide show
  1. package/README.md +55 -8
  2. package/package.json +5 -3
  3. package/src/cli.ts +33 -192
  4. package/src/db/memory-db.test.ts +54 -0
  5. package/src/db/memory-db.ts +122 -0
  6. package/src/db/sqlite-setup.ts +49 -0
  7. package/src/db/workflow-db.test.ts +41 -10
  8. package/src/db/workflow-db.ts +84 -28
  9. package/src/expression/evaluator.test.ts +19 -0
  10. package/src/expression/evaluator.ts +134 -39
  11. package/src/parser/schema.ts +41 -0
  12. package/src/runner/audit-verification.test.ts +23 -0
  13. package/src/runner/auto-heal.test.ts +64 -0
  14. package/src/runner/debug-repl.test.ts +74 -0
  15. package/src/runner/debug-repl.ts +225 -0
  16. package/src/runner/foreach-executor.ts +327 -0
  17. package/src/runner/llm-adapter.test.ts +27 -14
  18. package/src/runner/llm-adapter.ts +90 -112
  19. package/src/runner/llm-executor.test.ts +91 -6
  20. package/src/runner/llm-executor.ts +26 -6
  21. package/src/runner/mcp-client.audit.test.ts +69 -0
  22. package/src/runner/mcp-client.test.ts +12 -3
  23. package/src/runner/mcp-client.ts +199 -19
  24. package/src/runner/mcp-manager.ts +19 -8
  25. package/src/runner/mcp-server.test.ts +8 -5
  26. package/src/runner/mcp-server.ts +31 -17
  27. package/src/runner/optimization-runner.ts +305 -0
  28. package/src/runner/reflexion.test.ts +87 -0
  29. package/src/runner/shell-executor.test.ts +12 -0
  30. package/src/runner/shell-executor.ts +9 -6
  31. package/src/runner/step-executor.test.ts +46 -1
  32. package/src/runner/step-executor.ts +154 -60
  33. package/src/runner/stream-utils.test.ts +65 -0
  34. package/src/runner/stream-utils.ts +186 -0
  35. package/src/runner/workflow-runner.test.ts +4 -4
  36. package/src/runner/workflow-runner.ts +436 -251
  37. package/src/templates/agents/keystone-architect.md +6 -4
  38. package/src/templates/full-feature-demo.yaml +4 -4
  39. package/src/types/assets.d.ts +14 -0
  40. package/src/types/status.ts +1 -1
  41. package/src/ui/dashboard.tsx +38 -26
  42. package/src/utils/auth-manager.ts +3 -1
  43. package/src/utils/logger.test.ts +76 -0
  44. package/src/utils/logger.ts +39 -0
  45. package/src/utils/prompt.ts +75 -0
  46. package/src/utils/redactor.test.ts +86 -4
  47. 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
+ }