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
67 lines
2.1 KiB
TypeScript
67 lines
2.1 KiB
TypeScript
import { z } from "zod";
|
|
|
|
/**
|
|
* Runner process configuration, sourced from the environment by the
|
|
* provisioner (walking-skeleton story 06) or by the integration tests
|
|
* that spawn the runner directly.
|
|
*
|
|
* - `RUNNER_TOKEN` (required) — the workspace-scoped auth token every
|
|
* protocol envelope is gated on. A secret: env only, never argv.
|
|
* - `RUNNER_WORKSPACE_DIR` (required) — the directory this runner owns;
|
|
* the clone lands at `<workspaceDir>/repo`.
|
|
* - `RUNNER_PORT` (default `0`) — WS listen port; `0` binds an ephemeral
|
|
* port which `main.ts` announces on stdout (`event: "listening"`).
|
|
* - `RUNNER_HOST` (default `127.0.0.1`) — WS listen host.
|
|
* - `RUNNER_HEARTBEAT_MS` (default `1000`) — staged-progress heartbeat
|
|
* interval while a stage is running.
|
|
*/
|
|
const runnerConfigSchema = z
|
|
.object({
|
|
host: z.string().min(1),
|
|
port: z.number().int().min(0).max(65535),
|
|
token: z.string().min(1),
|
|
workspaceDir: z.string().min(1),
|
|
heartbeatMs: z.number().int().positive(),
|
|
})
|
|
.strict();
|
|
|
|
export type RunnerConfig = z.infer<typeof runnerConfigSchema>;
|
|
|
|
function parseIntegerEnv(
|
|
name: string,
|
|
raw: string | undefined,
|
|
fallback: number,
|
|
): number {
|
|
if (raw === undefined || raw === "") return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value)) {
|
|
throw new Error(`${name} must be an integer, received "${raw}"`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function loadConfigFromEnv(env: NodeJS.ProcessEnv): RunnerConfig {
|
|
const token = env.RUNNER_TOKEN;
|
|
if (token === undefined || token === "") {
|
|
throw new Error("RUNNER_TOKEN is required (workspace-scoped auth token)");
|
|
}
|
|
const workspaceDir = env.RUNNER_WORKSPACE_DIR;
|
|
if (workspaceDir === undefined || workspaceDir === "") {
|
|
throw new Error("RUNNER_WORKSPACE_DIR is required");
|
|
}
|
|
return runnerConfigSchema.parse({
|
|
host:
|
|
env.RUNNER_HOST === undefined || env.RUNNER_HOST === ""
|
|
? "127.0.0.1"
|
|
: env.RUNNER_HOST,
|
|
port: parseIntegerEnv("RUNNER_PORT", env.RUNNER_PORT, 0),
|
|
token,
|
|
workspaceDir,
|
|
heartbeatMs: parseIntegerEnv(
|
|
"RUNNER_HEARTBEAT_MS",
|
|
env.RUNNER_HEARTBEAT_MS,
|
|
1000,
|
|
),
|
|
});
|
|
}
|