import fs from "node:fs"; import path from "node:path"; import { execa } from "execa"; import { eq } from "drizzle-orm"; import { getDb } from "../db/client"; import { runs, type RunStatus } from "../db/schema"; import { getConfig, getScript } from "../config/load"; import type { ScriptConfig } from "../config/schema"; import { buildInvocation, type Invocation } from "./build-args"; import { registerRun, unregisterRun } from "./registry"; import { emitRunMessage } from "./events"; export class ScriptNotFoundError extends Error {} interface StartRunOptions { scriptId: string; variables: Record; triggeredBy: string; } export async function startRun({ scriptId, variables, triggeredBy, }: StartRunOptions): Promise { const script = getScript(scriptId); if (!script) throw new ScriptNotFoundError(`Unknown script '${scriptId}'`); const { logsDir } = getConfig(); fs.mkdirSync(logsDir, { recursive: true }); const invocation = buildInvocation(script, variables); const db = getDb(); const runId = crypto.randomUUID(); const logFilePath = path.join(logsDir, `${runId}.log`); db.insert(runs) .values({ id: runId, scriptId: script.id, scriptName: script.name, status: "queued", variables: invocation.redactedVariables, resolvedCommand: invocation.redactedCommandLine, timeoutSeconds: script.timeoutSeconds, triggeredBy, logFilePath, }) .run(); // Fire and forget - the caller gets the runId immediately, progress streams over WS/polling. void executeRun(runId, script, invocation, logFilePath).catch((error) => { console.error(`[runner] unhandled error executing run ${runId}:`, error); }); return runId; } async function executeRun( runId: string, script: ScriptConfig, invocation: Invocation, logFilePath: string, ) { const db = getDb(); const logStream = fs.createWriteStream(logFilePath, { flags: "a" }); const setStatus = ( status: RunStatus, extra: Partial = {}, ) => { db.update(runs) .set({ status, ...extra }) .where(eq(runs.id, runId)) .run(); emitRunMessage({ type: "status", runId, status, exitCode: (extra.exitCode as number | null | undefined) ?? null, ts: Date.now(), }); }; const { configDir } = getConfig(); const cwd = path.resolve(configDir, script.cwd); const controller = new AbortController(); let seq = 0; const onChunk = (stream: "stdout" | "stderr") => (data: Buffer) => { const chunk = data.toString("utf-8"); logStream.write(chunk); emitRunMessage({ type: "output", runId, stream, chunk, seq: seq++, ts: Date.now(), }); }; registerRun({ runId, scriptId: script.id, controller }); setStatus("running", { startedAt: new Date() }); try { const subprocess = execa(script.command, invocation.argv, { cwd, env: { ...process.env, ...invocation.env }, timeout: script.timeoutSeconds * 1000, cancelSignal: controller.signal, reject: false, shell: script.shell, input: invocation.stdin, buffer: false, }); subprocess.stdout?.on("data", onChunk("stdout")); subprocess.stderr?.on("data", onChunk("stderr")); const result = await subprocess; let status: RunStatus; if (result.isCanceled) status = "cancelled"; else if (result.timedOut) status = "timed_out"; else if (result.failed) status = "failed"; else status = "succeeded"; setStatus(status, { exitCode: result.exitCode ?? null, endedAt: new Date(), errorMessage: status === "failed" || status === "timed_out" ? (result.shortMessage ?? null) : null, }); } catch (error) { setStatus("failed", { endedAt: new Date(), errorMessage: (error as Error).message, }); } finally { logStream.end(); unregisterRun(runId); } } /** On boot, any DB row still `running`/`queued` has no live handle in this process - mark it interrupted * rather than pretending we can resume streaming its output. */ export function reconcileOrphanedRuns() { const db = getDb(); const now = new Date(); const orphaned = db .update(runs) .set({ status: "interrupted", endedAt: now, errorMessage: "Server restarted while this run was in progress.", }) .where(eq(runs.status, "running")) .run(); db.update(runs) .set({ status: "interrupted", endedAt: now, errorMessage: "Server restarted before this run could start.", }) .where(eq(runs.status, "queued")) .run(); return orphaned; }