Monorepo for Aesthetic.Computer
aesthetic.computer
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}