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.
- package/README.md +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +841 -91
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +484 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +491 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-execution.test.ts +39 -0
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +174 -93
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +534 -83
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +549 -15
- package/src/runner/workflow-runner.ts +1448 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +17 -12
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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;
|