// 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. // Uses a separate hash file (.last-papers-built-hash) to track state. 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"; const HASH_FILE = path.join(GIT_REPO_DIR, ".last-papers-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 these const 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}`); 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 { return null; } } async function writeLastBuiltHash(hash) { await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); } async function poll() { if (polling) return; polling = true; try { // Fetch latest from origin await git(["fetch", "origin", BRANCH, "--quiet"]); const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); const lastBuilt = await readLastBuiltHash(); 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; } // 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` ); // Trigger build const job = await startBuildFn({ ref: remoteHead, changed_paths: papersPaths.join(","), }); logFn( "info", "🚀", `Papers build ${job.id} started` ); // Update hash after successfully starting the build await writeLastBuiltHash(remoteHead); } catch (err) { if (err?.code === "PAPERS_BUILD_BUSY") { logFn("info", "⏳", "Papers build already running — will retry next poll"); } else { logFn( "error", "❌", `Papers git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}` ); } } finally { polling = false; } } // ── Public API ────────────────────────────────────────────────────────────── export function startPoller({ startPapersBuild, addServerLog }) { startBuildFn = startPapersBuild; if (addServerLog) logFn = addServerLog; // Check that GIT_REPO_DIR exists before starting fs.access(GIT_REPO_DIR) .then(() => { 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(() => { 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() { return { running: timer !== null, intervalMs: POLL_INTERVAL_MS, repoDir: GIT_REPO_DIR, branch: BRANCH, }; }