keystone-cli 0.7.2 → 1.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.
Files changed (104) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +841 -91
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +484 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +491 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-execution.test.ts +39 -0
  44. package/src/runner/standard-tools-integration.test.ts +36 -36
  45. package/src/runner/standard-tools.test.ts +18 -0
  46. package/src/runner/standard-tools.ts +174 -93
  47. package/src/runner/step-executor.test.ts +176 -16
  48. package/src/runner/step-executor.ts +534 -83
  49. package/src/runner/stream-utils.test.ts +14 -0
  50. package/src/runner/subflow-outputs.test.ts +103 -0
  51. package/src/runner/test-harness.ts +161 -0
  52. package/src/runner/tool-integration.test.ts +73 -79
  53. package/src/runner/workflow-runner.test.ts +549 -15
  54. package/src/runner/workflow-runner.ts +1448 -79
  55. package/src/runner/workflow-subflows.test.ts +255 -0
  56. package/src/templates/agents/keystone-architect.md +17 -12
  57. package/src/templates/agents/tester.md +21 -0
  58. package/src/templates/child-rollback.yaml +11 -0
  59. package/src/templates/decompose-implement.yaml +53 -0
  60. package/src/templates/decompose-problem.yaml +159 -0
  61. package/src/templates/decompose-research.yaml +52 -0
  62. package/src/templates/decompose-review.yaml +51 -0
  63. package/src/templates/dev.yaml +134 -0
  64. package/src/templates/engine-example.yaml +33 -0
  65. package/src/templates/fan-out-fan-in.yaml +61 -0
  66. package/src/templates/memory-service.yaml +1 -1
  67. package/src/templates/parent-rollback.yaml +16 -0
  68. package/src/templates/robust-automation.yaml +1 -1
  69. package/src/templates/scaffold-feature.yaml +29 -27
  70. package/src/templates/scaffold-generate.yaml +41 -0
  71. package/src/templates/scaffold-plan.yaml +53 -0
  72. package/src/types/status.ts +3 -0
  73. package/src/ui/dashboard.tsx +4 -3
  74. package/src/utils/assets.macro.ts +36 -0
  75. package/src/utils/auth-manager.ts +585 -8
  76. package/src/utils/blueprint-utils.test.ts +49 -0
  77. package/src/utils/blueprint-utils.ts +80 -0
  78. package/src/utils/circuit-breaker.test.ts +177 -0
  79. package/src/utils/circuit-breaker.ts +160 -0
  80. package/src/utils/config-loader.test.ts +100 -13
  81. package/src/utils/config-loader.ts +44 -17
  82. package/src/utils/constants.ts +62 -0
  83. package/src/utils/error-renderer.test.ts +267 -0
  84. package/src/utils/error-renderer.ts +320 -0
  85. package/src/utils/json-parser.test.ts +4 -0
  86. package/src/utils/json-parser.ts +18 -1
  87. package/src/utils/mermaid.ts +4 -0
  88. package/src/utils/paths.test.ts +46 -0
  89. package/src/utils/paths.ts +70 -0
  90. package/src/utils/process-sandbox.test.ts +128 -0
  91. package/src/utils/process-sandbox.ts +293 -0
  92. package/src/utils/rate-limiter.test.ts +143 -0
  93. package/src/utils/rate-limiter.ts +221 -0
  94. package/src/utils/redactor.test.ts +23 -15
  95. package/src/utils/redactor.ts +65 -25
  96. package/src/utils/resource-loader.test.ts +54 -0
  97. package/src/utils/resource-loader.ts +158 -0
  98. package/src/utils/sandbox.test.ts +69 -4
  99. package/src/utils/sandbox.ts +69 -6
  100. package/src/utils/schema-validator.ts +65 -0
  101. package/src/utils/workflow-registry.test.ts +57 -0
  102. package/src/utils/workflow-registry.ts +45 -25
  103. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  104. /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
@@ -0,0 +1,491 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import yaml from 'js-yaml';
6
+ import type { ExpressionContext } from '../expression/evaluator';
7
+ import { ExpressionEvaluator } from '../expression/evaluator';
8
+ import type { EngineStep } from '../parser/schema';
9
+ import { ConfigLoader } from '../utils/config-loader';
10
+ import { LIMITS } from '../utils/constants';
11
+ import { extractJson } from '../utils/json-parser';
12
+ import { ConsoleLogger, type Logger } from '../utils/logger';
13
+
14
+ /**
15
+ * Simple LRU cache with maximum size to prevent memory leaks.
16
+ */
17
+ class LRUCache<K, V> {
18
+ private cache = new Map<K, V>();
19
+
20
+ constructor(private maxSize: number) {}
21
+
22
+ get(key: K): V | undefined {
23
+ const value = this.cache.get(key);
24
+ if (value !== undefined) {
25
+ // Move to end (most recently used)
26
+ this.cache.delete(key);
27
+ this.cache.set(key, value);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ set(key: K, value: V): void {
33
+ // Delete existing to update order
34
+ if (this.cache.has(key)) {
35
+ this.cache.delete(key);
36
+ }
37
+ // Evict oldest if at capacity
38
+ if (this.cache.size >= this.maxSize) {
39
+ const oldest = this.cache.keys().next().value;
40
+ if (oldest !== undefined) {
41
+ this.cache.delete(oldest);
42
+ }
43
+ }
44
+ this.cache.set(key, value);
45
+ }
46
+
47
+ get size(): number {
48
+ return this.cache.size;
49
+ }
50
+ }
51
+
52
+ const VERSION_CACHE = new LRUCache<string, string>(LIMITS.VERSION_CACHE_MAX_SIZE);
53
+ const TRUNCATED_SUFFIX = '... [truncated output]';
54
+
55
+ function createOutputLimiter(maxBytes: number) {
56
+ let bytes = 0;
57
+ let text = '';
58
+ let truncated = false;
59
+
60
+ const append = (chunk: Buffer | string) => {
61
+ if (truncated || maxBytes <= 0) {
62
+ truncated = true;
63
+ return;
64
+ }
65
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
66
+ const remaining = maxBytes - bytes;
67
+ if (remaining <= 0) {
68
+ truncated = true;
69
+ return;
70
+ }
71
+ if (buffer.length <= remaining) {
72
+ text += buffer.toString();
73
+ bytes += buffer.length;
74
+ return;
75
+ }
76
+ text += buffer.subarray(0, remaining).toString();
77
+ bytes = maxBytes;
78
+ truncated = true;
79
+ };
80
+
81
+ const finalize = () => (truncated ? `${text}${TRUNCATED_SUFFIX}` : text);
82
+
83
+ return {
84
+ append,
85
+ finalize,
86
+ get truncated() {
87
+ return truncated;
88
+ },
89
+ };
90
+ }
91
+
92
+ export interface EngineExecutionResult {
93
+ stdout: string;
94
+ stderr: string;
95
+ exitCode: number;
96
+ stdoutTruncated?: boolean;
97
+ stderrTruncated?: boolean;
98
+ summary: unknown | null;
99
+ summarySource?: 'file' | 'stdout';
100
+ summaryFormat?: 'json' | 'yaml';
101
+ artifactPath?: string;
102
+ summaryError?: string;
103
+ }
104
+
105
+ export interface EngineExecutorOptions {
106
+ logger?: Logger;
107
+ abortSignal?: AbortSignal;
108
+ runId?: string;
109
+ stepExecutionId?: string;
110
+ artifactRoot?: string;
111
+ redactForStorage?: (value: unknown) => unknown;
112
+ }
113
+
114
+ function matchesPattern(value: string, pattern: string): boolean {
115
+ if (pattern.includes('*')) {
116
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
117
+ return new RegExp(`^${escaped}$`).test(value);
118
+ }
119
+ return value === pattern;
120
+ }
121
+
122
+ function isDenied(command: string, denylist: string[]): boolean {
123
+ const base = path.basename(command);
124
+ return denylist.some(
125
+ (pattern) => matchesPattern(command, pattern) || matchesPattern(base, pattern)
126
+ );
127
+ }
128
+
129
+ function resolveAllowlistEntry(
130
+ command: string,
131
+ allowlist: Record<
132
+ string,
133
+ { command: string; args?: string[]; version: string; versionArgs?: string[] }
134
+ >
135
+ ) {
136
+ const base = path.basename(command);
137
+ for (const [name, entry] of Object.entries(allowlist)) {
138
+ if (entry.command === command || entry.command === base || name === command || name === base) {
139
+ return { name, entry };
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Run a command and capture its output.
147
+ * @internal
148
+ */
149
+ async function runCommand(
150
+ command: string,
151
+ args: string[],
152
+ env: Record<string, string>,
153
+ cwd: string,
154
+ abortSignal?: AbortSignal
155
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
156
+ return new Promise((resolve, reject) => {
157
+ const child = spawn(command, args, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] });
158
+ const stdoutLimiter = createOutputLimiter(LIMITS.MAX_PROCESS_OUTPUT_BYTES);
159
+ const stderrLimiter = createOutputLimiter(LIMITS.MAX_PROCESS_OUTPUT_BYTES);
160
+
161
+ if (child.stdout) {
162
+ child.stdout.on('data', (chunk: Buffer) => {
163
+ stdoutLimiter.append(chunk);
164
+ });
165
+ }
166
+ if (child.stderr) {
167
+ child.stderr.on('data', (chunk: Buffer) => {
168
+ stderrLimiter.append(chunk);
169
+ });
170
+ }
171
+
172
+ const abortHandler = () => {
173
+ try {
174
+ child.kill();
175
+ } catch {
176
+ // Process may already be terminated - safe to ignore
177
+ }
178
+ };
179
+ if (abortSignal) {
180
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
181
+ }
182
+
183
+ child.on('error', (error) => {
184
+ if (abortSignal) {
185
+ abortSignal.removeEventListener('abort', abortHandler);
186
+ }
187
+ reject(error);
188
+ });
189
+ child.on('close', (code) => {
190
+ if (abortSignal) {
191
+ abortSignal.removeEventListener('abort', abortHandler);
192
+ }
193
+ resolve({
194
+ stdout: stdoutLimiter.finalize(),
195
+ stderr: stderrLimiter.finalize(),
196
+ exitCode: code ?? 0,
197
+ });
198
+ });
199
+ });
200
+ }
201
+
202
+ async function checkEngineVersion(
203
+ command: string,
204
+ versionArgs: string[],
205
+ env: Record<string, string>,
206
+ cwd: string,
207
+ abortSignal?: AbortSignal
208
+ ): Promise<string> {
209
+ const cacheKey = `${command}::${versionArgs.join(' ')}`;
210
+ const cached = VERSION_CACHE.get(cacheKey);
211
+ if (cached) return cached;
212
+
213
+ const result = await runCommand(command, versionArgs, env, cwd, abortSignal);
214
+ if (result.exitCode !== 0) {
215
+ throw new Error(
216
+ `Failed to check engine version (exit ${result.exitCode}): ${result.stderr || result.stdout}`
217
+ );
218
+ }
219
+ const output = `${result.stdout}\n${result.stderr}`.trim();
220
+ VERSION_CACHE.set(cacheKey, output);
221
+ return output;
222
+ }
223
+
224
+ function extractYamlBlock(text: string): string[] {
225
+ const blocks: string[] = [];
226
+ const regex = /```(?:yaml|yml)\s*([\s\S]*?)\s*```/gi;
227
+ let match = regex.exec(text);
228
+ while (match) {
229
+ blocks.push(match[1].trim());
230
+ match = regex.exec(text);
231
+ }
232
+ return blocks;
233
+ }
234
+
235
+ function parseStructuredSummary(text: string): { summary: unknown; format: 'json' | 'yaml' } {
236
+ if (!text || text.trim().length === 0) {
237
+ throw new Error('Empty summary');
238
+ }
239
+
240
+ try {
241
+ const parsed = extractJson(text);
242
+ if (parsed === null || typeof parsed !== 'object') {
243
+ throw new Error('Summary must be an object or array');
244
+ }
245
+ return { summary: parsed, format: 'json' };
246
+ } catch {
247
+ // Fall through to YAML
248
+ }
249
+
250
+ const yamlBlocks = extractYamlBlock(text);
251
+ for (const block of yamlBlocks) {
252
+ try {
253
+ const parsed = yaml.load(block);
254
+ if (typeof parsed === 'undefined') {
255
+ throw new Error('Empty YAML summary');
256
+ }
257
+ if (parsed === null || typeof parsed !== 'object') {
258
+ throw new Error('Summary must be an object or array');
259
+ }
260
+ return { summary: parsed, format: 'yaml' };
261
+ } catch {
262
+ // Try next block
263
+ }
264
+ }
265
+
266
+ const parsed = yaml.load(text);
267
+ if (typeof parsed === 'undefined') {
268
+ throw new Error('Empty YAML summary');
269
+ }
270
+ if (parsed === null || typeof parsed !== 'object') {
271
+ throw new Error('Summary must be an object or array');
272
+ }
273
+ return { summary: parsed, format: 'yaml' };
274
+ }
275
+
276
+ export async function executeEngineStep(
277
+ step: EngineStep,
278
+ context: ExpressionContext,
279
+ options: EngineExecutorOptions = {}
280
+ ): Promise<EngineExecutionResult> {
281
+ const logger = options.logger || new ConsoleLogger();
282
+ const abortSignal = options.abortSignal;
283
+
284
+ if (abortSignal?.aborted) {
285
+ throw new Error('Step canceled');
286
+ }
287
+
288
+ const command = ExpressionEvaluator.evaluateString(step.command, context);
289
+ const args = (step.args || []).map((arg) => ExpressionEvaluator.evaluateString(arg, context));
290
+ const cwd = ExpressionEvaluator.evaluateString(step.cwd, context);
291
+
292
+ // Security note: spawn() is used with stdio: ['pipe', 'pipe', 'pipe'], NOT shell: true
293
+ // This means args are passed directly to the process without shell interpretation.
294
+ // Combined with the allowlist and version check, this is secure against injection.
295
+
296
+ const env: Record<string, string> = {};
297
+ for (const [key, value] of Object.entries(step.env || {})) {
298
+ env[key] = ExpressionEvaluator.evaluateString(value, context);
299
+ }
300
+
301
+ if (!cwd) {
302
+ throw new Error(`Engine step "${step.id}" requires an explicit cwd`);
303
+ }
304
+ if (!step.env) {
305
+ throw new Error(`Engine step "${step.id}" requires an explicit env`);
306
+ }
307
+
308
+ const hasPath = Object.keys(env).some((key) => key.toLowerCase() === 'path');
309
+ if (!path.isAbsolute(command) && !hasPath) {
310
+ throw new Error(`Engine step "${step.id}" requires env.PATH when using a non-absolute command`);
311
+ }
312
+
313
+ const config = ConfigLoader.load();
314
+ const allowlist = config.engines?.allowlist || {};
315
+ const denylist = config.engines?.denylist || [];
316
+
317
+ if (isDenied(command, denylist)) {
318
+ throw new Error(`Engine command "${command}" is denied by engines.denylist`);
319
+ }
320
+
321
+ const allowlistMatch = resolveAllowlistEntry(command, allowlist);
322
+ if (!allowlistMatch) {
323
+ const allowed = Object.keys(allowlist);
324
+ const allowedList = allowed.length > 0 ? allowed.join(', ') : 'none';
325
+ throw new Error(`Engine command "${command}" is not in the allowlist. Allowed: ${allowedList}`);
326
+ }
327
+
328
+ const versionArgs = allowlistMatch.entry.versionArgs?.length
329
+ ? allowlistMatch.entry.versionArgs
330
+ : ['--version'];
331
+ const versionOutput = await checkEngineVersion(command, versionArgs, env, cwd, abortSignal);
332
+ if (!versionOutput.includes(allowlistMatch.entry.version)) {
333
+ throw new Error(
334
+ `Engine "${allowlistMatch.name}" version mismatch. Expected "${allowlistMatch.entry.version}", got "${versionOutput}"`
335
+ );
336
+ }
337
+
338
+ const artifactRoot = options.artifactRoot || path.join(process.cwd(), '.keystone', 'artifacts');
339
+ const runDir = options.runId ? path.join(artifactRoot, options.runId) : artifactRoot;
340
+ mkdirSync(runDir, { recursive: true });
341
+
342
+ const artifactId = options.stepExecutionId
343
+ ? `${options.stepExecutionId}-${randomUUID()}`
344
+ : randomUUID();
345
+ const artifactPath = path.join(runDir, `${step.id}-${artifactId}-summary.json`);
346
+ env.KEYSTONE_ENGINE_SUMMARY_PATH = artifactPath;
347
+
348
+ const inputValue =
349
+ step.input !== undefined ? ExpressionEvaluator.evaluateObject(step.input, context) : undefined;
350
+ const inputPayload =
351
+ inputValue === undefined
352
+ ? undefined
353
+ : typeof inputValue === 'string'
354
+ ? inputValue
355
+ : JSON.stringify(inputValue);
356
+
357
+ let stdout = '';
358
+ let stderr = '';
359
+ let stdoutBuffer = '';
360
+ let stderrBuffer = '';
361
+ const stdoutLimiter = createOutputLimiter(LIMITS.MAX_PROCESS_OUTPUT_BYTES);
362
+ const stderrLimiter = createOutputLimiter(LIMITS.MAX_PROCESS_OUTPUT_BYTES);
363
+
364
+ const flushLines = (buffer: string, writer: (line: string) => void): string => {
365
+ let next = buffer;
366
+ let idx = next.indexOf('\n');
367
+ while (idx !== -1) {
368
+ const line = next.slice(0, idx).replace(/\r$/, '');
369
+ writer(line);
370
+ next = next.slice(idx + 1);
371
+ idx = next.indexOf('\n');
372
+ }
373
+ return next;
374
+ };
375
+
376
+ const exitCode = await new Promise<number>((resolve, reject) => {
377
+ const child = spawn(command, args, { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
378
+
379
+ const abortHandler = () => {
380
+ try {
381
+ child.kill();
382
+ } catch {
383
+ // Process may already be terminated - safe to ignore
384
+ }
385
+ };
386
+ if (abortSignal) {
387
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
388
+ }
389
+
390
+ if (child.stdout) {
391
+ child.stdout.on('data', (chunk: Buffer) => {
392
+ const text = chunk.toString();
393
+ stdoutLimiter.append(chunk);
394
+ stdoutBuffer += text;
395
+ stdoutBuffer = flushLines(stdoutBuffer, (line) => logger.log(line));
396
+ });
397
+ child.stdout.on('end', () => {
398
+ if (stdoutBuffer.length > 0) {
399
+ logger.log(stdoutBuffer.replace(/\r$/, ''));
400
+ stdoutBuffer = '';
401
+ }
402
+ });
403
+ }
404
+
405
+ if (child.stderr) {
406
+ child.stderr.on('data', (chunk: Buffer) => {
407
+ const text = chunk.toString();
408
+ stderrLimiter.append(chunk);
409
+ stderrBuffer += text;
410
+ stderrBuffer = flushLines(stderrBuffer, (line) => logger.error(line));
411
+ });
412
+ child.stderr.on('end', () => {
413
+ if (stderrBuffer.length > 0) {
414
+ logger.error(stderrBuffer.replace(/\r$/, ''));
415
+ stderrBuffer = '';
416
+ }
417
+ });
418
+ }
419
+
420
+ if (inputPayload !== undefined && child.stdin) {
421
+ child.stdin.write(inputPayload);
422
+ child.stdin.end();
423
+ } else if (child.stdin) {
424
+ child.stdin.end();
425
+ }
426
+
427
+ child.on('error', (error) => {
428
+ if (abortSignal) {
429
+ abortSignal.removeEventListener('abort', abortHandler);
430
+ }
431
+ reject(error);
432
+ });
433
+ child.on('close', (code) => {
434
+ if (abortSignal) {
435
+ abortSignal.removeEventListener('abort', abortHandler);
436
+ }
437
+ resolve(code ?? 0);
438
+ });
439
+ });
440
+
441
+ stdout = stdoutLimiter.finalize();
442
+ stderr = stderrLimiter.finalize();
443
+
444
+ let summary: unknown | null = null;
445
+ let summarySource: 'file' | 'stdout' | undefined;
446
+ let summaryFormat: 'json' | 'yaml' | undefined;
447
+ let summaryError: string | undefined;
448
+
449
+ if (existsSync(artifactPath)) {
450
+ const fileText = await Bun.file(artifactPath).text();
451
+ if (fileText.trim().length > 0) {
452
+ try {
453
+ const parsed = parseStructuredSummary(fileText);
454
+ summary = parsed.summary;
455
+ summarySource = 'file';
456
+ summaryFormat = parsed.format;
457
+ } catch (error) {
458
+ summaryError = error instanceof Error ? error.message : String(error);
459
+ }
460
+ }
461
+ }
462
+
463
+ if (!summary && !summaryError) {
464
+ try {
465
+ const parsed = parseStructuredSummary(stdout);
466
+ summary = parsed.summary;
467
+ summarySource = 'stdout';
468
+ summaryFormat = parsed.format;
469
+ } catch (error) {
470
+ summaryError = error instanceof Error ? error.message : String(error);
471
+ }
472
+ }
473
+
474
+ if (summary !== null) {
475
+ const redacted = options.redactForStorage ? options.redactForStorage(summary) : summary;
476
+ await Bun.write(artifactPath, JSON.stringify(redacted, null, 2));
477
+ }
478
+
479
+ return {
480
+ stdout,
481
+ stderr,
482
+ exitCode,
483
+ stdoutTruncated: stdoutLimiter.truncated,
484
+ stderrTruncated: stderrLimiter.truncated,
485
+ summary,
486
+ summarySource,
487
+ summaryFormat,
488
+ artifactPath: summary !== null ? artifactPath : undefined,
489
+ summaryError,
490
+ };
491
+ }
@@ -4,6 +4,7 @@ import { type ExpressionContext, ExpressionEvaluator } from '../expression/evalu
4
4
  import type { Step } from '../parser/schema.ts';
5
5
  import { StepStatus, WorkflowStatus } from '../types/status.ts';
6
6
  import type { Logger } from '../utils/logger.ts';
7
+ import type { ResourcePoolManager } from './resource-pool.ts';
7
8
  import { WorkflowSuspendedError } from './step-executor.ts';
8
9
  import type { ForeachStepContext, StepContext } from './workflow-runner.ts';
9
10
 
@@ -20,7 +21,9 @@ export class ForeachExecutor {
20
21
  constructor(
21
22
  private db: WorkflowDb,
22
23
  private logger: Logger,
23
- private executeStepFn: ExecuteStepCallback
24
+ private executeStepFn: ExecuteStepCallback,
25
+ private abortSignal?: AbortSignal,
26
+ private resourcePool?: ResourcePoolManager
24
27
  ) {}
25
28
 
26
29
  /**
@@ -131,7 +134,7 @@ export class ForeachExecutor {
131
134
  .fill(null)
132
135
  .map(async () => {
133
136
  const nextIndex = () => {
134
- if (aborted) return null;
137
+ if (aborted || this.abortSignal?.aborted) return null;
135
138
  if (currentIndex >= items.length) return null;
136
139
  const i = currentIndex;
137
140
  currentIndex += 1;
@@ -142,7 +145,7 @@ export class ForeachExecutor {
142
145
  const i = nextIndex();
143
146
  if (i === null) break;
144
147
 
145
- if (aborted) break;
148
+ if (aborted || this.abortSignal?.aborted) break;
146
149
 
147
150
  const item = items[i];
148
151
 
@@ -176,6 +179,7 @@ export class ForeachExecutor {
176
179
  | typeof StepStatus.SUCCESS
177
180
  | typeof StepStatus.SKIPPED
178
181
  | typeof StepStatus.FAILED;
182
+ let itemError: string | undefined = existingExec.error || undefined;
179
183
 
180
184
  try {
181
185
  output = existingExec.output ? JSON.parse(existingExec.output) : null;
@@ -185,6 +189,7 @@ export class ForeachExecutor {
185
189
  );
186
190
  output = { error: 'Failed to parse output' };
187
191
  itemStatus = StepStatus.FAILED;
192
+ itemError = 'Failed to parse output';
188
193
  aborted = true; // Fail fast if we find corrupted data
189
194
  try {
190
195
  await this.db.completeStep(
@@ -206,25 +211,38 @@ export class ForeachExecutor {
206
211
  ? (output as Record<string, unknown>)
207
212
  : {},
208
213
  status: itemStatus,
214
+ error: itemError,
209
215
  } as StepContext;
210
216
  continue;
211
217
  }
212
218
 
213
- if (aborted) break;
219
+ if (aborted || this.abortSignal?.aborted) break;
214
220
 
215
221
  const stepExecId = randomUUID();
216
222
  await this.db.createStep(stepExecId, runId, step.id, i);
217
223
 
218
224
  // Execute and store result
219
225
  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;
226
+ if (aborted || this.abortSignal?.aborted) break;
227
+
228
+ const poolName = step.pool || step.type;
229
+ let release: (() => void) | undefined;
230
+
231
+ try {
232
+ if (this.resourcePool) {
233
+ release = await this.resourcePool.acquire(poolName, { signal: this.abortSignal });
234
+ }
235
+
236
+ this.logger.log(` ⤷ [${i + 1}/${items.length}] Executing iteration...`);
237
+ itemResults[i] = await this.executeStepFn(step, itemContext, stepExecId);
238
+ if (
239
+ itemResults[i].status === StepStatus.FAILED ||
240
+ itemResults[i].status === StepStatus.SUSPENDED
241
+ ) {
242
+ aborted = true;
243
+ }
244
+ } finally {
245
+ release?.();
228
246
  }
229
247
  } catch (error) {
230
248
  aborted = true;