Monorepo for Aesthetic.Computer aesthetic.computer
at main 217 lines 6.7 kB view raw
1// papers-git-poller.mjs — polls git for papers/ changes, auto-triggers PDF builds 2// 3// Runs inside the oven server. Every POLL_INTERVAL_MS (default 60s), fetches 4// origin/main and checks if any papers/ paths changed since the last 5// successful build. If so, pulls and triggers startPapersBuild(). 6// 7// Shares the git clone at GIT_REPO_DIR with native-git-poller.mjs. 8// Uses a separate hash file (.last-papers-built-hash) to track state. 9 10import { execFile } from "child_process"; 11import { promises as fs } from "fs"; 12import path from "path"; 13 14const POLL_INTERVAL_MS = parseInt(process.env.PAPERS_POLL_INTERVAL_MS || "60000", 10); 15const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; 16const BRANCH = process.env.NATIVE_GIT_BRANCH || "main"; 17const HASH_FILE = path.join(GIT_REPO_DIR, ".last-papers-built-hash"); 18 19// Paths that should trigger a papers rebuild (prefixes) 20const TRIGGER_PREFIXES = [ 21 "papers/", 22 "system/public/type/webfonts/", // font changes affect paper output 23]; 24 25// File extensions that are LaTeX source files (trigger a build). 26// Excludes .pdf and other build outputs to prevent infinite loops 27// when the oven pushes built PDFs back to the repo. 28const SOURCE_EXTENSIONS = [ 29 ".tex", ".bib", ".sty", ".cls", ".mjs", ".js", ".json", 30 ".png", ".jpg", ".jpeg", ".svg", ".eps", // figures 31]; 32 33// Paths generated by the oven build — always ignore these 34const IGNORE_PATTERNS = [ 35 "system/public/papers.aesthetic.computer/", // deployed PDFs + index.html 36 "papers/metadata.json", 37 "papers/BUILDLOG.md", 38]; 39 40function isSourceChange(filePath) { 41 // Synthetic force-rebuild paths always trigger 42 if (filePath === "papers/force-rebuild") return true; 43 // Ignore oven-generated output paths 44 if (IGNORE_PATTERNS.some((pat) => filePath.startsWith(pat) || filePath === pat)) { 45 return false; 46 } 47 // Must match a trigger prefix 48 if (!TRIGGER_PREFIXES.some((prefix) => filePath.startsWith(prefix))) { 49 return false; 50 } 51 // Font changes always trigger 52 if (filePath.startsWith("system/public/type/webfonts/")) return true; 53 // For papers/, only trigger on source file extensions (not .pdf, .log, .aux, etc.) 54 const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase(); 55 return SOURCE_EXTENSIONS.includes(ext); 56} 57 58let polling = false; 59let timer = null; 60let startBuildFn = null; 61let logFn = (level, icon, msg) => console.log(`[papers-git-poller] ${msg}`); 62 63function git(args, cwd = GIT_REPO_DIR) { 64 return new Promise((resolve, reject) => { 65 execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => { 66 if (err) { 67 err.stderr = stderr; 68 return reject(err); 69 } 70 resolve(stdout.trim()); 71 }); 72 }); 73} 74 75async function readLastBuiltHash() { 76 try { 77 return (await fs.readFile(HASH_FILE, "utf8")).trim(); 78 } catch { 79 return null; 80 } 81} 82 83async function writeLastBuiltHash(hash) { 84 await fs.writeFile(HASH_FILE, hash + "\n", "utf8"); 85} 86 87async function poll() { 88 if (polling) return; 89 polling = true; 90 91 try { 92 // Fetch latest from origin 93 await git(["fetch", "origin", BRANCH, "--quiet"]); 94 95 const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]); 96 const lastBuilt = await readLastBuiltHash(); 97 98 if (remoteHead === lastBuilt) { 99 polling = false; 100 return; 101 } 102 103 // Check which files changed 104 let changedPaths = ""; 105 if (lastBuilt) { 106 try { 107 const diffOutput = await git([ 108 "diff", 109 "--name-only", 110 lastBuilt, 111 remoteHead, 112 ]); 113 changedPaths = diffOutput; 114 } catch { 115 // lastBuilt hash might not exist (force push, etc) — treat as full build 116 changedPaths = "papers/force-rebuild"; 117 } 118 } else { 119 // First run — treat as full build 120 changedPaths = "papers/force-rebuild"; 121 } 122 123 // Filter to papers-relevant source paths (excludes .pdf and build outputs) 124 const papersPaths = changedPaths 125 .split("\n") 126 .filter((p) => isSourceChange(p)); 127 128 if (papersPaths.length === 0) { 129 // Changes exist but not in papers/ — update hash, skip build 130 logFn("info", "⏭️", `New commits (${remoteHead.slice(0, 8)}) but no papers/ changes — skipping build`); 131 await writeLastBuiltHash(remoteHead); 132 polling = false; 133 return; 134 } 135 136 // Pull the changes so cli.mjs works on up-to-date code 137 await git(["checkout", BRANCH, "--quiet"]); 138 await git(["merge", `origin/${BRANCH}`, "--ff-only", "--quiet"]); 139 140 logFn( 141 "info", 142 "📄", 143 `Papers changes detected (${remoteHead.slice(0, 8)}): ${papersPaths.length} file(s) — triggering PDF build` 144 ); 145 146 // Trigger build 147 const job = await startBuildFn({ 148 ref: remoteHead, 149 changed_paths: papersPaths.join(","), 150 }); 151 152 logFn( 153 "info", 154 "🚀", 155 `Papers build ${job.id} started` 156 ); 157 158 // Update hash after successfully starting the build 159 await writeLastBuiltHash(remoteHead); 160 } catch (err) { 161 if (err?.code === "PAPERS_BUILD_BUSY") { 162 logFn("info", "⏳", "Papers build already running — will retry next poll"); 163 } else { 164 logFn( 165 "error", 166 "❌", 167 `Papers git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}` 168 ); 169 } 170 } finally { 171 polling = false; 172 } 173} 174 175// ── Public API ────────────────────────────────────────────────────────────── 176 177export function startPoller({ startPapersBuild, addServerLog }) { 178 startBuildFn = startPapersBuild; 179 if (addServerLog) logFn = addServerLog; 180 181 // Check that GIT_REPO_DIR exists before starting 182 fs.access(GIT_REPO_DIR) 183 .then(() => { 184 logFn( 185 "info", 186 "📄", 187 `Papers git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})` 188 ); 189 // Stagger start vs native poller (native uses 5s delay, we use 15s) 190 setTimeout(poll, 15000); 191 timer = setInterval(poll, POLL_INTERVAL_MS); 192 }) 193 .catch(() => { 194 logFn( 195 "error", 196 "⚠️", 197 `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}` 198 ); 199 }); 200} 201 202export function stopPoller() { 203 if (timer) { 204 clearInterval(timer); 205 timer = null; 206 logFn("info", "🛑", "Papers git poller stopped"); 207 } 208} 209 210export function getPollerStatus() { 211 return { 212 running: timer !== null, 213 intervalMs: POLL_INTERVAL_MS, 214 repoDir: GIT_REPO_DIR, 215 branch: BRANCH, 216 }; 217}