feat(runner): WS protocol server with handshake
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
This commit is contained in:
113
apps/runner/tests/spawn-runner.ts
Normal file
113
apps/runner/tests/spawn-runner.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user