import { spawn, type ChildProcess } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const APP_ROOT = fileURLToPath(new URL("..", import.meta.url)); const START_TIMEOUT_MS = 60_000; const KILL_TIMEOUT_MS = 5_000; /** * A real runner child process, as the provisioner (story 06) will spawn * it: config via env only, port discovered from the stdout `listening` * line. `output()` exposes everything the process ever wrote — the * credential-leak assertions grep it. */ export interface SpawnedRunner { port: number; workspaceDir: string; /** Combined stdout + stderr captured so far. */ output: () => string; stop: () => Promise; } export async function spawnRunner(options: { token: string; }): Promise { const workspaceDir = await mkdtemp( path.join(os.tmpdir(), "veect-runner-ws-"), ); const child: ChildProcess = spawn( process.execPath, ["--import", "tsx", "src/main.ts"], { cwd: APP_ROOT, env: { ...process.env, RUNNER_TOKEN: options.token, RUNNER_WORKSPACE_DIR: workspaceDir, RUNNER_PORT: "0", RUNNER_HEARTBEAT_MS: "250", }, stdio: ["ignore", "pipe", "pipe"], }, ); let captured = ""; child.stdout?.on("data", (chunk: Buffer) => { captured += chunk.toString("utf8"); }); child.stderr?.on("data", (chunk: Buffer) => { captured += chunk.toString("utf8"); }); const stop = async (): Promise => { if (child.exitCode === null && child.signalCode === null) { const exited = new Promise((resolve) => child.once("exit", () => resolve()), ); const killTimer = setTimeout( () => child.kill("SIGKILL"), KILL_TIMEOUT_MS, ); killTimer.unref(); child.kill("SIGTERM"); await exited; clearTimeout(killTimer); } await rm(workspaceDir, { recursive: true, force: true }); }; const port = await new Promise((resolve, reject) => { const deadline = setTimeout(() => { reject( new Error( `runner did not announce a port within ${START_TIMEOUT_MS}ms:\n${captured}`, ), ); }, START_TIMEOUT_MS); const poll = setInterval(() => { if (child.exitCode !== null) { clearTimeout(deadline); clearInterval(poll); reject( new Error( `runner exited before listening (code ${child.exitCode}):\n${captured}`, ), ); return; } for (const line of captured.split("\n")) { if (!line.includes('"listening"')) continue; try { const parsed = JSON.parse(line) as { event?: string; port?: number }; if (parsed.event === "listening" && typeof parsed.port === "number") { clearTimeout(deadline); clearInterval(poll); resolve(parsed.port); return; } } catch { // partial line — keep polling } } }, 50); }).catch(async (error: unknown) => { await stop(); throw error; }); return { port, workspaceDir, output: () => captured, stop }; }