apps/runner scaffold (app-tier, walking-skeleton story 04): WS server speaking @repo/core-runner-protocol. Every inbound/outbound frame is envelope-wrapped and zod-parsed; hello/ready handshake gates on the workspace-scoped token (constant-time compare, redacted token on rejection replies); named error events for version/schema/auth rejections. Config via env only (token never argv); port announced on stdout for the story-06 provisioner. Runtime deps: ws (the standard Node WS server; ADR-022 traces do not apply to app-tier) and zod. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
114 lines
3.2 KiB
TypeScript
114 lines
3.2 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
export async function spawnRunner(options: {
|
|
token: string;
|
|
}): Promise<SpawnedRunner> {
|
|
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<void> => {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
const exited = new Promise<void>((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<number>((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 };
|
|
}
|