From d548d8a8a8d5219e94bb6a9ca1668c0293f0b727 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Fri, 24 Jul 2026 09:47:29 +0000 Subject: [PATCH] qa: hourly in-process heartbeat + run pruning - runner: QA_HEARTBEAT_MIN (default 60) in-process timer runs a scheduled QA pass; one pulse ~90s after boot. Triage (LLM) skipped on clean scheduled runs to save tokens; only manual runs or runs with errors get a written summary. - qa-finish.php prunes to the 50 most recent runs. - systemd unit exposes QA_HEARTBEAT_MIN for tuning. --- agents/console/server.mjs | 19 ++++++++++++++++++- api/cli/qa-finish.php | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 9f85c0c..f65a9da 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -796,7 +796,12 @@ async function runQaFlow(trigger) { runId = JSON.parse(start.out || "{}").run_id || 0; if (!runId) throw new Error("could not open a QA run"); const { findings, counts } = await runQa(); - const summary = await qaTriage(findings, counts); + // Triage (an LLM call) only when it's worth it: a manual run, or a + // scheduled heartbeat that actually found errors. Clean heartbeats get a + // cheap templated summary — no tokens spent when the site is fine. + const summary = (trigger === "manual" || counts.error > 0) + ? await qaTriage(findings, counts) + : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`; const tmp = `/tmp/qa-${runId}.json`; writeFileSync(tmp, JSON.stringify(findings)); await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]); @@ -993,3 +998,15 @@ if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); server.listen(PORT, "127.0.0.1", () => { console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`); }); + +// ---- QA heartbeat ------------------------------------------------------------ +// The runner is long-lived, so the QA heartbeat is just an in-process timer — +// no cron, no extra units. Runs a scheduled QA pass every QA_HEARTBEAT_MIN +// minutes (0 disables), plus one pulse shortly after boot. runQaFlow self-guards +// against overlap and is read-only against the live site. +const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60); +if (QA_HEARTBEAT_MIN > 0) { + setTimeout(() => runQaFlow("scheduled"), 90_000); // first pulse ~90s after boot + setInterval(() => runQaFlow("scheduled"), QA_HEARTBEAT_MIN * 60_000); + console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`); +} diff --git a/api/cli/qa-finish.php b/api/cli/qa-finish.php index a1d7d09..0122335 100644 --- a/api/cli/qa-finish.php +++ b/api/cli/qa-finish.php @@ -61,4 +61,11 @@ foreach ($findings as $f) { $upd = $pdo->prepare('UPDATE cja_qa_runs SET status = ?, summary = ?, counts = ?, finished_at = NOW() WHERE run_id = ?'); $upd->execute([$status, $summary, $counts, $runId]); +// Prune to the 50 most recent runs (the heartbeat runs often). +$cut = (int) ($pdo->query('SELECT run_id FROM cja_qa_runs ORDER BY run_id DESC LIMIT 1 OFFSET 50')->fetchColumn() ?: 0); +if ($cut > 0) { + $pdo->prepare('DELETE FROM cja_qa_findings WHERE run_id < ?')->execute([$cut]); + $pdo->prepare('DELETE FROM cja_qa_runs WHERE run_id < ?')->execute([$cut]); +} + echo json_encode(['ok' => true, 'run_id' => $runId, 'findings' => $n]);