Files
mozempkandCopilot d56f058b1d feat: initial commit of ralph-runtime
Governed app-lifecycle primitives for agentic loops: supervised serve
(hold-open, reaped) and one-shot smoke (HTTP + console verdict).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-10 08:59:48 +02:00

255 lines
9.1 KiB
JavaScript

import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
// ── Config ───────────────────────────────────────────────────────────────────
// Project config: ralph.config.json at repo root. Missing → auto-detect (vite).
// Fields: kind, port, url, routes[], serveStart, smokeBuild, smokeStart, env{}, ttlSeconds.
const DEFAULT_PORT = 4173;
function readJSON(p) {
try {
return JSON.parse(readFileSync(p, "utf-8"));
} catch {
return null;
}
}
function depHas(pkg, name) {
return !!(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]);
}
function autoDetect(root) {
const pkg = readJSON(join(root, "package.json")) ?? {};
const scripts = pkg.scripts ?? {};
const port = DEFAULT_PORT;
const url = `http://localhost:${port}`;
const isVite = depHas(pkg, "vite") || depHas(pkg, "@sveltejs/kit") || !!scripts.preview;
if (isVite) {
return {
kind: "web",
port,
url,
routes: ["/"],
// serve (exploration): dev server gives source errors + HMR; WE supervise+reap it.
serveStart: scripts.dev ? `npm run dev -- --port ${port} --strictPort` : `npx vite dev --port ${port} --strictPort`,
// smoke (deterministic): production build + preview.
smokeBuild: scripts.build ? "npm run build" : "npx vite build",
smokeStart: `npx vite preview --port ${port} --strictPort`,
env: {},
ttlSeconds: 1800,
};
}
// generic node server fallback
return {
kind: scripts.start ? "server" : "cli",
port,
url,
routes: ["/"],
serveStart: scripts.start ? "npm start" : "",
smokeBuild: scripts.build ? "npm run build" : "",
smokeStart: scripts.start ? "npm start" : "",
env: {},
ttlSeconds: 1800,
};
}
export function loadConfig(root) {
const declared = readJSON(join(root, "ralph.config.json"));
const auto = autoDetect(root);
const cfg = { ...auto, ...(declared ?? {}) };
cfg.url = cfg.url ?? `http://localhost:${cfg.port}`;
cfg.routes = cfg.routes ?? ["/"];
cfg.env = cfg.env ?? {};
return cfg;
}
// ── Paths ────────────────────────────────────────────────────────────────────
export function ralphDir(root) {
const d = join(root, ".ralph");
if (!existsSync(d)) mkdirSync(d, { recursive: true });
return d;
}
export const pidPath = (root) => join(ralphDir(root), "serve.pid");
export const logPath = (root) => join(ralphDir(root), "serve.log");
// ── Port hygiene ─────────────────────────────────────────────────────────────
export function killPort(port) {
// try fuser, then lsof — best-effort, never throws
spawnSync("bash", ["-c", `fuser -k ${port}/tcp 2>/dev/null; true`], { timeout: 8000 });
spawnSync("bash", ["-c", `lsof -ti tcp:${port} 2>/dev/null | xargs -r kill -9 2>/dev/null; true`], { timeout: 8000 });
}
// ── Process helpers ──────────────────────────────────────────────────────────
// Spawn a server in its OWN process group (detached:true calls setsid() for the
// child → child.pid == pgid), so we can kill the whole tree with kill(-pid)
// without ever signalling our own group. Returns the child (group leader).
export function spawnServer(startCmd, cwd, env, logFd) {
return spawn("bash", ["-c", startCmd], {
cwd,
env: { ...process.env, ...env, CI: "1", NO_COLOR: "1", FORCE_COLOR: "0", BROWSER: "none" },
stdio: ["ignore", logFd, logFd],
detached: true,
});
}
export function killGroup(pid, signal = "SIGTERM") {
if (!pid || pid === process.pid || pid === process.ppid) return; // never signal ourselves
try {
process.kill(-pid, signal); // negative pid = the child's own process group
} catch {
try {
process.kill(pid, signal);
} catch {
/* already dead */
}
}
}
export function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// ── wait-on (bounded, no dep) ────────────────────────────────────────────────
export async function waitOn(url, timeoutMs = 60000, intervalMs = 500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url, { method: "GET", redirect: "manual", headers: { Connection: "close" } });
if (res.status > 0) return true; // any HTTP response = server is up
} catch {
/* not up yet */
}
await sleep(intervalMs);
}
return false;
}
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── HTTP probe ───────────────────────────────────────────────────────────────
export async function httpProbe(baseUrl, routes, expectStatus = 200) {
const rows = [];
for (const route of routes) {
const url = baseUrl.replace(/\/$/, "") + route;
const t0 = Date.now();
try {
const res = await fetch(url, { redirect: "manual", headers: { Connection: "close" } });
const ms = Date.now() - t0;
const ctype = res.headers.get("content-type") ?? "";
const body = await res.text();
const expected = typeof expectStatus === "object" ? (expectStatus[route] ?? 200) : expectStatus;
const redirect = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
rows.push({
route,
status: res.status,
ms,
ctype: ctype.split(";")[0],
bytes: Buffer.byteLength(body),
redirect,
ok: res.status === expected,
snippet: res.status < 200 || res.status >= 300 ? body.replace(/\s+/g, " ").trim().slice(0, 200) : "",
});
} catch (e) {
rows.push({ route, status: 0, ms: Date.now() - t0, error: String(e.message), ok: false });
}
}
return rows;
}
// ── Browser probe (Playwright chromium, headless) ────────────────────────────
// Degrades gracefully to a note if playwright isn't installed.
export async function browserProbe(baseUrl, route, actions = []) {
let chromium;
try {
({ chromium } = await import("playwright"));
} catch {
return {
available: false,
note: "browser probe skipped — playwright not installed. Run: cd ~/Sources/ralph-runtime && npm i playwright && npx playwright install chromium",
consoleErrors: [],
consoleMessages: [],
ok: true, // don't fail the smoke just because the browser probe is unavailable
};
}
const errors = []; // pageerror + console.error — drives the verdict
const messages = []; // EVERY console.* message (log/info/warn/error/debug), in order
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
page.on("pageerror", (e) => {
const t = String(e.message || e);
errors.push(t);
messages.push({ type: "pageerror", text: t });
});
page.on("console", (m) => {
const type = m.type();
const text = m.text();
messages.push({ type, text });
if (type === "error") errors.push(text);
});
const url = baseUrl.replace(/\/$/, "") + route;
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
for (const a of actions) {
try {
const [verb, ...rest] = a.split(/\s+/);
if (verb === "click" && rest.length >= 2) {
await page.mouse.click(Number(rest[0]), Number(rest[1]));
} else if (verb === "clickText") {
await page.getByText(rest.join(" ")).first().click({ timeout: 5000 });
} else if (verb === "wait") {
await sleep(Number(rest[0]) || 500);
}
await sleep(300);
} catch (e) {
errors.push(`action "${a}" failed: ${String(e.message)}`);
}
}
await sleep(400); // let async errors surface
return { available: true, consoleErrors: errors, consoleMessages: messages, ok: errors.length === 0 };
} catch (e) {
const t = `navigation failed: ${String(e.message)}`;
return { available: true, consoleErrors: [...errors, t], consoleMessages: [...messages, { type: "pageerror", text: t }], ok: false };
} finally {
if (browser) await browser.close().catch(() => {});
}
}
// ── misc ─────────────────────────────────────────────────────────────────────
export function tailFile(p, n = 40) {
try {
const lines = readFileSync(p, "utf-8").split("\n");
return lines.slice(-n).join("\n");
} catch {
return "";
}
}
export function writePid(root, pid, ttlSeconds) {
writeFileSync(pidPath(root), JSON.stringify({ pid, startedAt: Date.now(), ttlSeconds }));
}
export function readPid(root) {
return readJSON(pidPath(root));
}
export function clearPid(root) {
try {
writeFileSync(pidPath(root), "");
} catch {
/* ignore */
}
}