import { type ChildProcess, spawn as nodeSpawn } from "node:child_process"; import { WorkflowError, WorkflowErrorCode } from "./errors.js"; const DEFAULT_STDERR_LIMIT = 16 * 1024; const DEFAULT_TERM_GRACE_MS = 1_000; const DEFAULT_KILL_GRACE_MS = 2_000; const MAX_JSONL_ERRORS = 32; export interface JsonlParseError { line: string; error: string; lineNumber: number; } export interface JsonlParserResult { events: T[]; errors: JsonlParseError[]; remainder: string; } /** Incremental JSON-lines parser suitable for stdout streams split at any byte. */ export class IncrementalJsonlParser { private buffer = ""; private lineNumber = 0; private readonly events: T[] = []; private readonly errors: JsonlParseError[] = []; push(chunk: string | Uint8Array): void { this.buffer += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); let newline = this.buffer.indexOf("\n"); while (newline >= 0) { const line = this.buffer.slice(0, newline).replace(/\r$/, ""); this.buffer = this.buffer.slice(newline + 1); this.consume(line); newline = this.buffer.indexOf("\n"); } } finish(): JsonlParserResult { if (this.buffer.length > 0) { this.consume(this.buffer.replace(/\r$/, "")); this.buffer = ""; } return this.result(); } result(): JsonlParserResult { return { events: [...this.events], errors: [...this.errors], remainder: this.buffer }; } private consume(line: string): void { this.lineNumber++; if (!line.trim()) return; try { this.events.push(JSON.parse(line) as T); } catch (error) { if (this.errors.length < MAX_JSONL_ERRORS) { this.errors.push({ line: line.slice(0, 4_096), error: error instanceof Error ? error.message : String(error), lineNumber: this.lineNumber, }); } } } } export function parseJsonl(text: string): JsonlParserResult { const parser = new IncrementalJsonlParser(); parser.push(text); return parser.finish(); } export interface CliSpawnOptions { cwd?: string; env?: NodeJS.ProcessEnv; prompt?: string; signal?: AbortSignal; stderrLimit?: number; terminateGraceMs?: number; killGraceMs?: number; /** Injectable in tests; defaults to node:child_process.spawn. */ spawn?: SpawnFactory; } export interface SpawnedCliProcess { stdin: NodeJS.WritableStream | null; stdout: NodeJS.ReadableStream | null; stderr: NodeJS.ReadableStream | null; once(event: "close" | "error", listener: (...args: any[]) => void): this; kill(signal?: NodeJS.Signals | number): boolean; } export type SpawnFactory = ( command: string, args: readonly string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; shell: false; stdio: ["pipe", "pipe", "pipe"] }, ) => SpawnedCliProcess; export interface CliProcessResult { command: string; args: string[]; exitCode: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string; events: T[]; jsonlErrors: JsonlParseError[]; } /** Abort error that retains protocol output parsed before process termination. */ export class CliProcessAbortedError extends WorkflowError { readonly partialResult: CliProcessResult; constructor(partialResult: CliProcessResult) { super("executor process was aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true, details: partialResult, }); this.name = "CliProcessAbortedError"; this.partialResult = partialResult; } } export function isCliProcessAbortedError(error: unknown): error is CliProcessAbortedError { return error instanceof CliProcessAbortedError; } function defaultSpawn( command: string, args: readonly string[], options: Parameters[2], ): ChildProcess { return nodeSpawn(command, [...args], options); } /** * Run one CLI process with no shell interpretation. It always closes stdin, * incrementally parses stdout as JSONL, bounds stderr, and drains the process * through close. Abort sends SIGTERM first and SIGKILL after a bounded grace. */ export async function runCliProcess( command: string, args: readonly string[] = [], options: CliSpawnOptions = {}, ): Promise> { if (options.signal?.aborted) { throw new WorkflowError("executor process was aborted before start", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true, }); } const spawn = options.spawn ?? defaultSpawn; const env = options.env ? { ...options.env } : { ...process.env }; const parser = new IncrementalJsonlParser(); const stderrLimit = Math.max(0, options.stderrLimit ?? DEFAULT_STDERR_LIMIT); const termGrace = Math.max(0, options.terminateGraceMs ?? DEFAULT_TERM_GRACE_MS); const killGrace = Math.max(1, options.killGraceMs ?? DEFAULT_KILL_GRACE_MS); let stderr = ""; let stdout = ""; let child: SpawnedCliProcess; try { child = spawn(command, args, { cwd: options.cwd, env, shell: false, stdio: ["pipe", "pipe", "pipe"] }); } catch (error) { throw classifySpawnError(command, error); } const appendStderr = (chunk: string | Uint8Array) => { if (stderr.length >= stderrLimit) return; const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); stderr += text.slice(0, stderrLimit - stderr.length); }; const onStdout = (chunk: string | Uint8Array) => { const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); stdout += text; parser.push(text); }; child.stdout?.on("data", onStdout); child.stderr?.on("data", appendStderr); let aborted = false; let termTimer: ReturnType | undefined; let killTimer: ReturnType | undefined; let abortListener: (() => void) | undefined; let settled = false; let finishProcess: ((exitCode?: number | null, signal?: NodeJS.Signals | null) => void) | undefined; const terminate = () => { if (settled) return; aborted = true; try { child.kill("SIGTERM"); } catch { // The process may have exited between the close check and kill. } termTimer = setTimeout(() => { if (settled) return; try { child.kill("SIGKILL"); } catch { // Best effort; close below remains authoritative. } killTimer = setTimeout(() => { // A real child exits after SIGKILL. This timer only protects unusual test // doubles or a detached process from keeping the harness promise forever. if (!settled) finishProcess?.(undefined, "SIGKILL"); }, killGrace); }, termGrace); }; const result = await new Promise>((resolve, reject) => { const finish = (exitCode: number | null | undefined, signal: NodeJS.Signals | null | undefined) => { if (settled) return; settled = true; if (termTimer) clearTimeout(termTimer); if (killTimer) clearTimeout(killTimer); if (abortListener) options.signal?.removeEventListener("abort", abortListener); const parsed = parser.finish(); const processResult: CliProcessResult = { command, args: [...args], exitCode: exitCode ?? null, signal: signal ?? null, stdout, stderr, events: parsed.events, jsonlErrors: parsed.errors, }; if (aborted) { reject(new CliProcessAbortedError(processResult)); return; } resolve(processResult); }; finishProcess = finish; child.once("close", (code: number | null, signal: NodeJS.Signals | null) => finish(code, signal)); child.once("error", (error: Error) => { if (settled) return; if ((error as NodeJS.ErrnoException).code === "ENOENT") { settled = true; if (termTimer) clearTimeout(termTimer); if (killTimer) clearTimeout(killTimer); options.signal?.removeEventListener("abort", abortListener as () => void); reject(classifySpawnError(command, error)); } // Other errors are followed by close for ChildProcess. Let close provide // the final exit metadata and avoid resolving before stdout is drained. }); if (options.signal) { abortListener = terminate; options.signal.addEventListener("abort", abortListener, { once: true }); if (options.signal.aborted) terminate(); } try { child.stdin?.end(options.prompt ?? ""); } catch (error) { terminate(); reject( new WorkflowError( `could not write prompt to ${command}: ${error instanceof Error ? error.message : String(error)}`, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details: error }, ), ); } }); return result; } function classifySpawnError(command: string, error: unknown): WorkflowError { const message = error instanceof Error ? error.message : String(error); const errno = error as NodeJS.ErrnoException; if (errno?.code === "ENOENT" || /not found|enoent/i.test(message)) { return new WorkflowError(`executor binary unavailable: ${command}`, WorkflowErrorCode.EXECUTOR_UNAVAILABLE, { recoverable: false, details: error, }); } return new WorkflowError( `could not start executor ${command}: ${message}`, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details: error, }, ); } /** Convert bounded CLI diagnostics into a concise error detail string. */ export function boundedDiagnostics(stderr: string, maxChars = DEFAULT_STDERR_LIMIT): string { if (stderr.length <= maxChars) return stderr; return `${stderr.slice(0, Math.max(0, maxChars - 20))}... [stderr truncated]`; }