#!/usr/bin/env node // Vectrum runner: picks up workflow jobs from a Vectrum server and runs them on this machine. // // node vectrum-runner.mjs --url https://vectrum.example --token vrn_… // // Options (or environment variables): // --url VECTRUM_URL the Vectrum server // --token VECTRUM_RUNNER_TOKEN the token shown when the runner was registered // --work-dir VECTRUM_WORK_DIR where jobs check out code (default ./_work) // --once run one job, then exit // --keep-workspace leave each job's checkout behind // // Needs Node.js 18+ and git. Steps run as the user running this script, with its permissions: // only register runners for repositories whose workflows you trust. import { spawn, spawnSync } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { StringDecoder } from "node:string_decoder"; const VERSION = "1.0.0"; function parseArgs(argv) { const opts = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--once" || arg === "--keep-workspace") opts[arg.slice(2)] = true; else if (arg === "--help" || arg === "-h") opts.help = true; else if (arg.startsWith("--")) { const [key, inline] = arg.slice(2).split("=", 2); opts[key] = inline ?? argv[++i]; } else fail(`Unknown argument: ${arg}`); } return opts; } function fail(message) { console.error(`vectrum-runner: ${message}`); process.exit(1); } const opts = parseArgs(process.argv.slice(2)); if (opts.help) { console.log("Usage: node vectrum-runner.mjs --url --token [--work-dir ] [--once] [--keep-workspace]"); process.exit(0); } const SERVER = (opts.url ?? process.env.VECTRUM_URL ?? "").replace(/\/+$/, ""); const TOKEN = opts.token ?? process.env.VECTRUM_RUNNER_TOKEN ?? ""; const WORK_DIR = path.resolve(opts["work-dir"] ?? process.env.VECTRUM_WORK_DIR ?? "_work"); if (!SERVER) fail("pass the server with --url (or VECTRUM_URL)"); if (!TOKEN.startsWith("vrn_")) fail("pass the runner token with --token (or VECTRUM_RUNNER_TOKEN)"); const OS = { linux: "linux", darwin: "macos", win32: "windows" }[process.platform] ?? process.platform; const ARCH = { x64: "x64", arm64: "arm64", arm: "arm", ia32: "x86" }[process.arch] ?? process.arch; const IS_WINDOWS = process.platform === "win32"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const stamp = () => new Date().toISOString().replace("T", " ").slice(0, 19); const say = (message) => console.log(`${stamp()} ${message}`); class ApiError extends Error { constructor(message, status) { super(message); this.status = status; } } async function api(pathname, body, signal) { const res = await fetch(`${SERVER}${pathname}`, { method: "POST", headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: JSON.stringify(body), signal, }); if (res.status === 204) return null; const data = await res.json().catch(() => ({})); if (!res.ok) throw new ApiError(data.error ?? `HTTP ${res.status}`, res.status); return data; } // --------------------------------------------------------------------------- // Reporting progress /** Batches step changes and output, sending them about once a second (and a heartbeat when quiet). */ class Reporter { constructor(jobId) { this.jobId = jobId; this.steps = new Map(); this.logs = []; this.cancelled = false; this.lost = false; this.lastSent = 0; this.sending = Promise.resolve(); this.timer = setInterval(() => { if (this.logs.length || this.steps.size || Date.now() - this.lastSent > 15_000) void this.flush(); }, 1000); } step(index, status, exitCode) { this.steps.set(index, { index, status, exitCode }); } log(step, text) { const last = this.logs[this.logs.length - 1]; if (last && last.step === step && last.text.length < 64 * 1024) last.text += text; else this.logs.push({ step, text }); } /** Sends what is pending; calls are queued so updates arrive in order. */ flush(extra = {}) { this.sending = this.sending.then(async () => { if (this.lost) return; const body = { steps: [...this.steps.values()], logs: this.logs, ...extra }; this.steps = new Map(); this.logs = []; for (let attempt = 0; ; attempt++) { try { const res = await api(`/api/runner/jobs/${this.jobId}`, body); this.lastSent = Date.now(); if (res?.cancelled) this.cancelled = true; return; } catch (e) { if (e instanceof ApiError && (e.status === 409 || e.status === 401)) { // The server gave the job up (or the runner was removed): stop working on it. say(` ${e.message}`); this.lost = true; this.cancelled = true; return; } if (attempt >= 20) { this.lost = true; this.cancelled = true; return; } await sleep(Math.min(1000 * 2 ** attempt, 15_000)); } } }); return this.sending; } async finish(status) { clearInterval(this.timer); await this.flush({ status }); } } // --------------------------------------------------------------------------- // Running commands /** Stops a process and everything it started. */ function killTree(child) { if (child.exitCode !== null || child.signalCode !== null) return; if (IS_WINDOWS) { spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); return; } try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch { // Already gone. } }, 5000).unref(); } /** * Runs a command, streaming its output to `onOutput`. Resolves with its exit code, or with * "cancelled" / "timeout" if `shouldStop` said so or `timeoutMs` passed first. */ function execute(command, args, { cwd, env, onOutput, shouldStop, timeoutMs }) { return new Promise((resolve) => { let child; try { child = spawn(command, args, { cwd, env, detached: !IS_WINDOWS, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); } catch (e) { onOutput(`${e.message}\n`); resolve(127); return; } let stopped = null; const decoders = [new StringDecoder("utf8"), new StringDecoder("utf8")]; child.stdout.on("data", (d) => onOutput(decoders[0].write(d))); child.stderr.on("data", (d) => onOutput(decoders[1].write(d))); const watch = setInterval(() => { if (!stopped && shouldStop()) { stopped = "cancelled"; killTree(child); } }, 500); const timer = timeoutMs ? setTimeout(() => { stopped = "timeout"; killTree(child); }, timeoutMs) : null; child.on("error", (e) => onOutput(`${e.message}\n`)); child.on("close", (code, signal) => { clearInterval(watch); if (timer) clearTimeout(timer); onOutput(decoders[0].end() + decoders[1].end()); resolve(stopped ?? (code ?? (signal ? 128 : 1))); }); }); } const hasCommand = (cmd) => !spawnSync(cmd, IS_WINDOWS ? ["-NoProfile", "-Command", "exit 0"] : ["-c", "true"], { stdio: "ignore" }).error; const DEFAULT_SHELL = IS_WINDOWS ? (hasCommand("pwsh") ? "pwsh" : "powershell") : hasCommand("bash") ? "bash" : "sh"; /** How a step's script file is run, by its `shell:` (GitHub's conventions). */ function shellCommand(shell, file) { switch (shell) { case "bash": return { ext: ".sh", command: "bash", args: ["--noprofile", "--norc", "-eo", "pipefail", file] }; case "sh": return { ext: ".sh", command: "sh", args: ["-e", file] }; case "pwsh": case "powershell": return { ext: ".ps1", command: shell, args: ["-NoProfile", "-NonInteractive", "-Command", `$ErrorActionPreference = 'stop'; & '${file}'; if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }`] }; case "cmd": return { ext: ".cmd", command: "cmd", args: ["/D", "/E:ON", "/V:OFF", "/S", "/C", `CALL "${file}"`] }; case "python": return { ext: ".py", command: IS_WINDOWS ? "python" : "python3", args: [file] }; case "node": return { ext: ".mjs", command: "node", args: [file] }; default: { // A custom command line, with {0} where the script file goes: "perl {0}". if (!shell.includes("{0}")) return null; const [command, ...args] = shell.trim().split(/\s+/).map((part) => part.replace("{0}", file)); return { ext: "", command, args }; } } } // --------------------------------------------------------------------------- // Jobs async function checkout(job, workspace, reporter, shouldStop) { const auth = Buffer.from(`x-access-token:${job.token}`).toString("base64"); const out = (text) => reporter.log(0, text); const git = (args) => { out(`$ git ${args.map((a) => (a.startsWith("http.extraHeader=") ? "http.extraHeader=***" : a)).join(" ")}\n`); return execute("git", args, { cwd: workspace, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, onOutput: out, shouldStop }); }; out(`Checking out ${job.repository} at ${job.sha.slice(0, 7)} (${job.ref})\n`); const init = await git(["init", "--quiet"]); if (init !== 0) return init; // The token goes in a header for this one command, so it isn't stored in .git/config. const fetched = await git([ "-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", "fetch", "--no-tags", "--depth=1", "--quiet", job.cloneUrl, job.sha, ]); if (fetched !== 0) return fetched; return git(["checkout", "--quiet", "--detach", "FETCH_HEAD"]); } async function runJob(job) { const workspace = path.join(WORK_DIR, String(job.id)); const scripts = path.join(WORK_DIR, `${job.id}-scripts`); const reporter = new Reporter(job.id); say(`Running ${job.repository} ${job.env.VECTRUM_WORKFLOW} #${job.env.VECTRUM_RUN_NUMBER} · ${job.env.VECTRUM_JOB}`); let status; try { status = await runSteps(job, workspace, scripts, reporter); } catch (e) { reporter.log(0, `\nThe runner failed: ${e.stack ?? e.message}\n`); status = "failure"; } await reporter.finish(status); say(` ${status === "success" ? "Succeeded" : status === "failure" ? "Failed" : "Cancelled"}`); await rm(scripts, { recursive: true, force: true }).catch(() => {}); if (!opts["keep-workspace"]) await rm(workspace, { recursive: true, force: true }).catch(() => {}); } /** Checks out the code and runs the steps; returns the job's result. */ async function runSteps(job, workspace, scripts, reporter) { const shouldStop = () => reporter.cancelled || stopping; const deadline = Date.now() + job.timeoutMinutes * 60_000; let failed = false; let timedOut = false; await rm(workspace, { recursive: true, force: true }); await mkdir(workspace, { recursive: true }); await mkdir(scripts, { recursive: true }); reporter.step(0, "in_progress"); const checkedOut = await checkout(job, workspace, reporter, shouldStop); if (checkedOut !== 0) { failed = true; reporter.log(0, checkedOut === "cancelled" ? "Cancelled.\n" : `Checkout failed (${checkedOut}).\n`); } reporter.step(0, checkedOut === 0 ? "success" : checkedOut === "cancelled" ? "cancelled" : "failure", typeof checkedOut === "number" ? checkedOut : null); for (const step of job.steps) { const cancelled = shouldStop() || timedOut; const run = step.if === "always" || (cancelled ? false : step.if === "failure" ? failed : !failed); if (!run) { reporter.step(step.index, "skipped"); continue; } reporter.step(step.index, "in_progress"); const out = (text) => reporter.log(step.index, text); const shell = shellCommand(step.shell ?? DEFAULT_SHELL, ""); if (!shell) { out(`Unknown shell “${step.shell}”. Use bash, sh, pwsh, powershell, cmd, python, node, or a command with {0}.\n`); reporter.step(step.index, "failure"); if (!step.continueOnError) failed = true; continue; } const file = path.join(scripts, `step-${step.index}${shell.ext}`); await writeFile(file, step.run); const { command, args } = shellCommand(step.shell ?? DEFAULT_SHELL, file); const cwd = step.workingDirectory ? path.resolve(workspace, step.workingDirectory) : workspace; const stepTimeout = step.timeoutMinutes ? step.timeoutMinutes * 60_000 : Infinity; const remaining = deadline - Date.now(); const result = await execute(command, args, { cwd, env: { ...process.env, ...job.env, ...step.env, VECTRUM_WORKSPACE: workspace, RUNNER_OS: OS, RUNNER_ARCH: ARCH }, onOutput: out, // always() steps run on after a cancel; only stopping the runner interrupts them. shouldStop: step.if === "always" ? () => stopping : shouldStop, timeoutMs: Math.max(1000, Math.min(stepTimeout, remaining)), }); if (result === 0) { reporter.step(step.index, "success", 0); } else if (result === "cancelled") { out("\nCancelled.\n"); reporter.step(step.index, "cancelled"); } else { if (result === "timeout") { out(`\nThe step ran out of time.\n`); if (Date.now() >= deadline - 1000) timedOut = true; } else { out(`\nProcess exited with code ${result}.\n`); } reporter.step(step.index, "failure", typeof result === "number" ? result : null); if (!step.continueOnError) failed = true; } } if (timedOut) reporter.log(job.steps.at(-1)?.index ?? 0, `\nThe job ran longer than its ${job.timeoutMinutes} minute limit.\n`); return reporter.cancelled || stopping ? "cancelled" : failed || timedOut ? "failure" : "success"; } // --------------------------------------------------------------------------- // Main loop let stopping = false; let busy = false; process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); function onSignal() { if (stopping || !busy) process.exit(130); stopping = true; say("Stopping: cancelling the current job (press Ctrl-C again to quit now)…"); } const info = { hostname: os.hostname(), os: OS, arch: ARCH, version: VERSION }; say(`Vectrum runner ${VERSION} on ${info.hostname} (${OS}/${ARCH}), waiting for jobs from ${SERVER}`); let failures = 0; while (!stopping) { let job; try { job = (await api("/api/runner/claim", info, AbortSignal.timeout(60_000)))?.job; failures = 0; } catch (e) { if (e instanceof ApiError && e.status === 401) fail(e.message); failures++; say(`Can't reach ${SERVER}: ${e.cause?.message ?? e.message}. Retrying…`); await sleep(Math.min(2000 * failures, 30_000)); continue; } if (!job) continue; busy = true; await runJob(job); busy = false; if (opts.once) break; }