Monorepo for Aesthetic.Computer
aesthetic.computer
1// native-git-poller.mjs — polls git for fedac/native/ changes, auto-triggers OTA builds
2//
3// Runs inside the oven server. Every POLL_INTERVAL_MS (default 60s), fetches
4// origin/main from the configured native checkout remote and checks if any fedac/native/ paths changed since the last
5// successful build. If so, pulls and triggers startNativeBuild().
6//
7// Requires a git clone at GIT_REPO_DIR (default /opt/oven/native-git/).
8// deploy.sh sets this up on first deploy.
9
10import { execFile } from "child_process";
11import { promises as fs } from "fs";
12import path from "path";
13
14const POLL_INTERVAL_MS = parseInt(process.env.NATIVE_POLL_INTERVAL_MS || "0", 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-built-hash");
18
19let polling = false;
20let timer = null;
21let startBuildFn = null; // set via startPoller()
22let logFn = (level, icon, msg) => console.log(`[native-git-poller] ${msg}`);
23
24function git(args, cwd = GIT_REPO_DIR) {
25 return new Promise((resolve, reject) => {
26 execFile("git", args, { cwd, timeout: 30_000 }, (err, stdout, stderr) => {
27 if (err) {
28 err.stderr = stderr;
29 return reject(err);
30 }
31 resolve(stdout.trim());
32 });
33 });
34}
35
36async function readLastBuiltHash() {
37 try {
38 return (await fs.readFile(HASH_FILE, "utf8")).trim();
39 } catch {
40 return null;
41 }
42}
43
44async function writeLastBuiltHash(hash) {
45 await fs.writeFile(HASH_FILE, hash + "\n", "utf8");
46}
47
48async function poll() {
49 if (polling) return;
50 polling = true;
51
52 try {
53 // Fetch latest from origin
54 await git(["fetch", "origin", BRANCH, "--quiet"]);
55
56 const remoteHead = await git(["rev-parse", `origin/${BRANCH}`]);
57 const lastBuilt = await readLastBuiltHash();
58
59 if (remoteHead === lastBuilt) {
60 // No new commits
61 polling = false;
62 return;
63 }
64
65 // Check which files changed
66 let changedPaths = "";
67 if (lastBuilt) {
68 try {
69 const diffOutput = await git([
70 "diff",
71 "--name-only",
72 lastBuilt,
73 remoteHead,
74 ]);
75 changedPaths = diffOutput;
76 } catch {
77 // lastBuilt hash might not exist (force push, etc) — treat as full build
78 changedPaths = "fedac/native/src/force-rebuild";
79 }
80 } else {
81 // First run — treat as full build
82 changedPaths = "fedac/native/src/force-rebuild";
83 }
84
85 // Filter to fedac/native/ and fedac/nixos/ paths
86 const allPaths = changedPaths.split("\n");
87 const nativePaths = allPaths.filter((p) => p.startsWith("fedac/native/"));
88 const nixosPaths = allPaths.filter((p) => p.startsWith("fedac/nixos/"));
89
90 if (nativePaths.length === 0 && nixosPaths.length === 0) {
91 // Changes exist but not in fedac/native/ or fedac/nixos/ — update hash, skip build
92 logFn("info", "⏭️", `New commits (${remoteHead.slice(0, 8)}) but no fedac/ build changes — skipping build`);
93 await writeLastBuiltHash(remoteHead);
94 polling = false;
95 return;
96 }
97
98 // Determine variant based on which directories changed
99 let variant = "c";
100 if (nixosPaths.length > 0 && nativePaths.length === 0) variant = "nix";
101 else if (nixosPaths.length > 0 && nativePaths.length > 0) variant = "all";
102
103 // Hard-sync to origin so stale local edits/conflicts cannot leak into OTA builds.
104 await git(["checkout", "-f", BRANCH, "--quiet"]);
105 await git(["reset", "--hard", `origin/${BRANCH}`, "--quiet"]);
106 await git(["clean", "-fdq"]);
107
108 const relevantPaths = [...nativePaths, ...nixosPaths];
109 logFn(
110 "info",
111 "🔨",
112 `Build changes detected (${remoteHead.slice(0, 8)}): ${relevantPaths.length} file(s), variant=${variant} — triggering OTA build`
113 );
114
115 // Trigger build
116 const job = await startBuildFn({
117 ref: remoteHead,
118 changed_paths: relevantPaths.join(","),
119 variant,
120 });
121
122 logFn(
123 "info",
124 "🚀",
125 `OTA build ${job.id} started (flags: ${job.flags.join(" ") || "full"})`
126 );
127
128 // Update hash after successfully starting (not completing) the build
129 await writeLastBuiltHash(remoteHead);
130 } catch (err) {
131 if (err?.code === "NATIVE_BUILD_BUSY") {
132 // Build already running — skip, will retry next poll
133 logFn("info", "⏳", "Build already running — will retry next poll");
134 } else {
135 logFn(
136 "error",
137 "❌",
138 `Git poll error: ${err.message}${err.stderr ? " | " + err.stderr.trim() : ""}`
139 );
140 }
141 } finally {
142 polling = false;
143 }
144}
145
146// ── Public API ──────────────────────────────────────────────────────────────
147
148export function startPoller({ startNativeBuild, addServerLog, nativeDir }) {
149 startBuildFn = startNativeBuild;
150 if (addServerLog) logFn = addServerLog;
151
152 // Override NATIVE_DIR in the builder's env so it uses our git checkout
153 if (nativeDir !== false) {
154 process.env.NATIVE_DIR = path.join(GIT_REPO_DIR, "fedac", "native");
155 }
156
157 // Check that GIT_REPO_DIR exists before starting
158 if (POLL_INTERVAL_MS <= 0) {
159 logFn("info", "🛑", "Native git poller disabled (NATIVE_POLL_INTERVAL_MS=0). Use manual POST /native-build to trigger.");
160 return;
161 }
162
163 fs.access(GIT_REPO_DIR)
164 .then(() => {
165 logFn(
166 "info",
167 "👁️",
168 `Native git poller started (every ${POLL_INTERVAL_MS / 1000}s, repo: ${GIT_REPO_DIR})`
169 );
170 // First poll after a short delay to let the server settle
171 setTimeout(poll, 5000);
172 timer = setInterval(poll, POLL_INTERVAL_MS);
173 })
174 .catch(() => {
175 logFn(
176 "error",
177 "⚠️",
178 `Native git poller disabled — repo dir not found: ${GIT_REPO_DIR}. Run: git clone --branch main https://tangled.org/aesthetic.computer/core.git ${GIT_REPO_DIR}`
179 );
180 });
181}
182
183export function stopPoller() {
184 if (timer) {
185 clearInterval(timer);
186 timer = null;
187 logFn("info", "🛑", "Native git poller stopped");
188 }
189}
190
191export function getPollerStatus() {
192 return {
193 running: timer !== null,
194 intervalMs: POLL_INTERVAL_MS,
195 repoDir: GIT_REPO_DIR,
196 branch: BRANCH,
197 };
198}