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>
255 lines
9.1 KiB
JavaScript
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 */
|
|
}
|
|
}
|