Monorepo for Aesthetic.Computer aesthetic.computer
at main 198 lines 6.3 kB view raw
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}