Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257// 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 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}`);
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 (process.env.PAPERS_POLLER_DISABLED === "1") return; 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; }
// 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 const job = await startBuildFn({ ref: remoteHead, changed_paths: papersPaths.join(","), });
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 { 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;
if (process.env.PAPERS_POLLER_DISABLED === "1") { logFn("info", "⏸️", "Papers git poller disabled via PAPERS_POLLER_DISABLED=1 — not starting"); return; }
// 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, };}