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>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user