Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357// papers-git-poller.mjs β polls git for papers/ changes, auto-triggers PDF builds//// Runs inside the oven server. Every POLL_INTERVAL_MS (default 60s), fetches// origin/main and checks if any papers/ paths changed since the last// successful build. If so, pulls and triggers startPapersBuild().//// Shares the git clone at GIT_REPO_DIR with native-git-poller.mjs.// Keeps its own build marker under .git/oven-state/ β see the note below for// why it cannot live in the worktree.
import { execFile } from "child_process";import { promises as fs } from "fs";import path from "path";
const POLL_INTERVAL_MS = parseInt(process.env.PAPERS_POLL_INTERVAL_MS || "60000", 10);const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git";const BRANCH = process.env.NATIVE_GIT_BRANCH || "main";
// The "already built" marker lives inside .git/, not in the worktree.//// It used to be <repo>/.last-papers-built-hash β and on 2026-03-18 an// over-broad `git add -A` in the builder committed that file to main. From// then on it was a *tracked* file holding poller state, so every cleanup the// pipeline runs at the end of a build β `git checkout -- .` in// papers-builder.mjs, `git reset --hard` + `git clean -fd` in// native-git-poller.mjs β silently reverted it to the committed value// (aaa62f4a, 2026-03-21). The poller then re-derived the same months-wide diff// on the very next tick and rebuilt the entire mill again, pushing a fresh// metadata commit each lap. That is the "papers auto-build commit loop" the// poller was hand-paused for on 2026-05-13.//// .git/ is the one directory none of those cleanups touch, so state kept here// survives a build instead of being undone by it.const STATE_DIR = path.join(GIT_REPO_DIR, ".git", "oven-state");const HASH_FILE = path.join(STATE_DIR, "papers-last-built-hash");
// Paths that should trigger a papers rebuild (prefixes)const TRIGGER_PREFIXES = [ "papers/", "system/public/type/webfonts/", // font changes affect paper output];
// File extensions that are LaTeX source files (trigger a build).// Excludes .pdf and other build outputs to prevent infinite loops// when the oven pushes built PDFs back to the repo.const SOURCE_EXTENSIONS = [ ".tex", ".bib", ".sty", ".cls", ".mjs", ".js", ".json", ".png", ".jpg", ".jpeg", ".svg", ".eps", // figures];
// Paths generated by the oven build β always ignore theseconst IGNORE_PATTERNS = [ "system/public/papers.aesthetic.computer/", // deployed PDFs + index.html "papers/metadata.json", "papers/BUILDLOG.md",];
function isSourceChange(filePath) { // Synthetic force-rebuild paths always trigger if (filePath === "papers/force-rebuild") return true; // Ignore oven-generated output paths if (IGNORE_PATTERNS.some((pat) => filePath.startsWith(pat) || filePath === pat)) { return false; } // Must match a trigger prefix if (!TRIGGER_PREFIXES.some((prefix) => filePath.startsWith(prefix))) { return false; } // Font changes always trigger if (filePath.startsWith("system/public/type/webfonts/")) return true; // For papers/, only trigger on source file extensions (not .pdf, .log, .aux, etc.) const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); return SOURCE_EXTENSIONS.includes(ext);}
let polling = false;let timer = null;let startBuildFn = null;let logFn = (level, icon, msg) => console.log(`[papers-git-poller] ${msg}`);
// Health, reported verbatim by GET /papers-build so that "is the papermill// alive?" is answerable without shelling into the box. `running: false` on its// own never said *why* β that is how a systemd drop-in kept this off for four// months without anyone noticing.const health = { enabled: true, disabledReason: null, startedAt: null, lastPollAt: null, lastPollOk: null, lastError: null, consecutiveErrors: 0, lastBuiltHash: null, lastTriggerAt: null, lastTriggeredJobId: null,};
function markDisabled(reason) { health.enabled = false; health.disabledReason = reason;}
function git(args, cwd = GIT_REPO_DIR) { return new Promise((resolve, reject) => { execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => { if (err) { err.stderr = stderr; return reject(err); } resolve(stdout.trim()); }); });}
async function readLastBuiltHash() { try { return (await fs.readFile(HASH_FILE, "utf8")).trim(); } catch { // No marker β treat as a first run and rebuild the whole mill once. The // legacy worktree file is deliberately NOT read as a fallback: its value is // the stale commit the loop kept restoring, so trusting it would just start // the treadmill again. To skip the one-time full rebuild, seed this file by // hand with the commit whose PDFs are already deployed. return null; }}
async function writeLastBuiltHash(hash) { await fs.mkdir(STATE_DIR, { recursive: true }); await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); health.lastBuiltHash = hash;}
async function poll() { if (process.env.PAPERS_POLLER_DISABLED === "1") { markDisabled("PAPERS_POLLER_DISABLED=1"); return; } if (polling) return; polling = true; health.lastPollAt = new Date().toISOString();
try { // Fetch latest from origin await git(["fetch", "origin", BRANCH, "--quiet"]);
const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); const lastBuilt = await readLastBuiltHash(); health.lastBuiltHash = lastBuilt; health.lastPollOk = true; health.lastError = null; health.consecutiveErrors = 0;
if (remoteHead === lastBuilt) { polling = false; return; }
// Check which files changed let changedPaths = ""; if (lastBuilt) { try { const diffOutput = await git([ "diff", "--name-only", lastBuilt, remoteHead, ]); changedPaths = diffOutput; } catch { // lastBuilt hash might not exist (force push, etc) β treat as full build changedPaths = "papers/force-rebuild"; } } else { // First run β treat as full build changedPaths = "papers/force-rebuild"; }
// Filter to papers-relevant source paths (excludes .pdf and build outputs) const papersPaths = changedPaths .split("\n") .filter((p) => isSourceChange(p));
if (papersPaths.length === 0) { // Changes exist but not in papers/ β update hash, skip build logFn("info", "βοΈ", `New commits (${remoteHead.slice(0, 8)}) but no papers/ changes β skipping build`); await writeLastBuiltHash(remoteHead); polling = false; return; }
// Self-commit skip: if every commit in lastBuilt..remoteHead was authored // by the oven itself, this is just our own auto-build push echoing back. // Don't re-trigger β that's the positive-feedback loop. We only reach this // branch when isSourceChange() let some path through (e.g. a non-deterministic // PDF that landed inside papers/ rather than the IGNORE_PATTERNS prefix). if (lastBuilt) { try { const authorOutput = await git([ "log", `${lastBuilt}..${remoteHead}`, "--format=%ae", ]); const authors = new Set( authorOutput.split("\n").map((a) => a.trim()).filter(Boolean), ); if (authors.size > 0 && authors.size === 1 && authors.has("oven@aesthetic.computer")) { logFn( "info", "βοΈ", `Commits ${lastBuilt.slice(0, 8)}..${remoteHead.slice(0, 8)} are all oven auto-builds β skipping`, ); await writeLastBuiltHash(remoteHead); polling = false; return; } } catch (authorErr) { logFn("error", "β οΈ", `Self-commit author check failed: ${authorErr.message}`); } }
// Pull the changes so cli.mjs works on up-to-date code await git(["checkout", BRANCH, "--quiet"]); await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]);
logFn( "info", "π", `Papers changes detected (${remoteHead.slice(0, 8)}): ${papersPaths.length} file(s) β triggering PDF build` );
// Defensive: write the hash BEFORE starting the build so we don't retry // the same commit if startBuildFn throws PAPERS_BUILD_BUSY or any other // error path leaves writeLastBuiltHash unreached. A failed build can be // re-triggered by pushing a new commit or deleting the hash file. await writeLastBuiltHash(remoteHead);
// Trigger build health.lastTriggerAt = new Date().toISOString(); const job = await startBuildFn({ ref: remoteHead, changed_paths: papersPaths.join(","), });
health.lastTriggeredJobId = job.id; logFn( "info", "π", `Papers build ${job.id} started` ); } catch (err) { if (err?.code === "PAPERS_BUILD_BUSY") { logFn("info", "β³", "Papers build already running β will retry next poll"); } else { health.lastPollOk = false; health.consecutiveErrors += 1; health.lastError = { at: new Date().toISOString(), message: `${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}`, }; logFn( "error", "β", `Papers git poll error (${health.consecutiveErrors} in a row): ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}` ); } } finally { polling = false; }}
// ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function startPoller({ startPapersBuild, addServerLog }) { startBuildFn = startPapersBuild; if (addServerLog) logFn = addServerLog;
if (process.env.PAPERS_POLLER_DISABLED === "1") { markDisabled("PAPERS_POLLER_DISABLED=1"); logFn( "error", "βΈοΈ", "Papers git poller OFF β PAPERS_POLLER_DISABLED=1. Nothing in this repo " + "sets it, so it came from the unit: check " + "/etc/systemd/system/oven.service.d/*.conf on the oven.", ); return; }
// Check that GIT_REPO_DIR exists before starting fs.access(GIT_REPO_DIR) .then(() => { health.enabled = true; health.disabledReason = null; health.startedAt = new Date().toISOString(); logFn( "info", "π", `Papers git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})` ); // Stagger start vs native poller (native uses 5s delay, we use 15s) setTimeout(poll, 15000); timer = setInterval(poll, POLL_INTERVAL_MS); }) .catch(() => { markDisabled(`repo dir not found: ${GIT_REPO_DIR}`); logFn( "error", "β οΈ", `Papers git poller disabled β repo dir not found: ${GIT_REPO_DIR}. Run: git clone --branch main https://github.com/whistlegraph/aesthetic-computer.git ${GIT_REPO_DIR}` ); });}
export function stopPoller() { if (timer) { clearInterval(timer); timer = null; logFn("info", "π", "Papers git poller stopped"); }}
export function getPollerStatus() { const running = timer !== null; const sinceLastPollMs = health.lastPollAt ? Date.now() - Date.parse(health.lastPollAt) : null; return { running, // `enabled` is the *intent*, `running` the fact; when they disagree, // `disabledReason` says which switch did it. enabled: health.enabled, disabledReason: health.disabledReason, // A poller whose timer exists but whose ticks have stopped landing (a // wedged git fetch, say) looks identical to a healthy one from `running` // alone. Three missed intervals is the line. healthy: running && health.enabled && sinceLastPollMs !== null && sinceLastPollMs < POLL_INTERVAL_MS * 3 && health.consecutiveErrors < 3, intervalMs: POLL_INTERVAL_MS, repoDir: GIT_REPO_DIR, branch: BRANCH, startedAt: health.startedAt, lastPollAt: health.lastPollAt, sinceLastPollMs, lastPollOk: health.lastPollOk, consecutiveErrors: health.consecutiveErrors, lastError: health.lastError, lastBuiltHash: health.lastBuiltHash, lastTriggerAt: health.lastTriggerAt, lastTriggeredJobId: health.lastTriggeredJobId, };}