Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811// native-builder.mjs — FedAC Native kernel OTA builds for oven//// Triggered via POST /native-build after commits to fedac/native/ on main.// Runs build-and-flash.sh (no --flash) then upload-release.sh.// Models os-base-build.mjs.
import { promises as fs } from "fs";import path from "path";import { randomUUID } from "crypto";import { spawn } from "child_process";import { MongoClient } from "mongodb";
function runSync(cmd, args, cwd, env = null) { return new Promise((resolve) => { const proc = spawn(cmd, args, { cwd, env: env ? { ...process.env, ...env } : process.env, stdio: ['ignore', 'pipe', 'ignore'], }); let out = ''; proc.stdout.on('data', d => out += d); proc.on('close', () => resolve(out.trim())); proc.on('error', () => resolve('')); });}
const MAX_RECENT_JOBS = 10;const MAX_LOG_LINES = 2000;const NATIVE_BUILD_COLLECTION = process.env.NATIVE_BUILD_COLLECTION || "oven-native-builds";
// fedac/native/ lives in the native-git repo on oven (polled by native-git-poller).const NATIVE_DIR = process.env.NATIVE_DIR || "/opt/oven/native-git/fedac/native";const NATIVE_BRANCH = process.env.NATIVE_GIT_BRANCH || "main";const NIX_DATA_PARTITION_MIB = process.env.NIX_DATA_PARTITION_MIB || "512";const MEDIA_HELPER_IMAGE = process.env.AC_MEDIA_HELPER_IMAGE || "ac-os-media-helper:img-v1";const NIX_BIN_CANDIDATES = [ process.env.NIX_BIN || "", "/usr/local/bin/nix", "/nix/var/nix/profiles/default/bin/nix", "/home/oven/.nix-profile/bin/nix", "/root/.nix-profile/bin/nix",];const NIX_GC_CANDIDATES = [ process.env.NIX_GC_BIN || "", "/usr/local/bin/nix-collect-garbage", "/nix/var/nix/profiles/default/bin/nix-collect-garbage", "/home/oven/.nix-profile/bin/nix-collect-garbage", "/root/.nix-profile/bin/nix-collect-garbage",];
// Kernel build cache: symlinked from fedac/native/build so kernel object// files survive rsync --delete between commits (5-10x faster warm builds).const CACHE_DIR = process.env.NATIVE_CACHE_DIR || "/opt/oven/native-cache";
const jobs = new Map();const jobOrder = [];let activeJobId = null;
let progressCallback = null;let lastProgressBroadcast = 0;let nativeBuildMongoClient = null;let nativeBuildMongoDb = null;
export function onNativeBuildProgress(cb) { progressCallback = cb;}
function nowISO() { return new Date().toISOString();}
function uniqueNonEmpty(items) { return [...new Set((items || []).filter(Boolean))];}
function toDateOrNull(v) { if (!v) return null; const d = new Date(v); if (Number.isNaN(d.getTime())) return null; return d;}
async function getNativeBuildMongo() { if (nativeBuildMongoDb) return nativeBuildMongoDb; const uri = process.env.MONGODB_CONNECTION_STRING; const dbName = process.env.MONGODB_NAME; if (!uri || !dbName) return null; try { nativeBuildMongoClient = await MongoClient.connect(uri); nativeBuildMongoDb = nativeBuildMongoClient.db(dbName); await nativeBuildMongoDb .collection(NATIVE_BUILD_COLLECTION) .createIndex({ when: -1 }); await nativeBuildMongoDb .collection(NATIVE_BUILD_COLLECTION) .createIndex({ buildName: 1, when: -1 }); return nativeBuildMongoDb; } catch (err) { console.error("[native-builder] MongoDB connect failed:", err.message); return null; }}
async function persistNativeBuildRecord(job) { try { const db = await getNativeBuildMongo(); if (!db) return; const startedAt = toDateOrNull(job.startedAt); const finishedAt = toDateOrNull(job.finishedAt); const durationMs = startedAt && finishedAt ? Math.max(0, finishedAt - startedAt) : null; const record = { jobId: job.id, buildName: job.buildName || null, ref: job.ref || null, gitHash: job.ref && job.ref !== "unknown" ? String(job.ref).slice(0, 40) : null, status: job.status || "unknown", stage: job.stage || null, percent: Number.isFinite(job.percent) ? job.percent : null, error: job.error || null, exitCode: Number.isFinite(job.exitCode) ? job.exitCode : null, commitMsg: job.commitMsg || null, flags: Array.isArray(job.flags) ? job.flags : [], changedPaths: job.changedPaths || "", variant: job.variant || "c", createdAt: toDateOrNull(job.createdAt), startedAt, updatedAt: toDateOrNull(job.updatedAt), finishedAt, durationMs, logCount: Array.isArray(job.logs) ? job.logs.length : 0, logTail: Array.isArray(job.logs) ? job.logs.slice(-120).map((l) => l.line) : [], source: "oven-native-builder", when: new Date(), }; await db.collection(NATIVE_BUILD_COLLECTION).insertOne(record); } catch (err) { console.error("[native-builder] Failed to persist build record:", err.message); }}
function stripAnsi(s) { return String(s || "").replace(/\u001b\[[0-9;]*m/g, "");}
async function resolveBinary(cmd, candidates = [], cwd = NATIVE_DIR) { const fromPath = await runSync("bash", ["-lc", `command -v ${cmd} || true`], cwd); if (fromPath) return fromPath.split("\n").pop().trim(); for (const candidate of uniqueNonEmpty(candidates)) { try { await fs.access(candidate); return candidate; } catch {} } return "";}
function addLogLine(job, stream, line) { const clean = stripAnsi(line).replace(/\r/g, "").trimEnd(); if (!clean) return; job.logs.push({ ts: nowISO(), stream, line: clean }); if (job.logs.length > MAX_LOG_LINES) job.logs.splice(0, job.logs.length - MAX_LOG_LINES); job.updatedAt = nowISO();
// Broadcast every log line with the last few lines attached if (progressCallback) { const now = Date.now(); // Full snapshot every 2s, lightweight log-only message in between if (now - lastProgressBroadcast > 2000) { lastProgressBroadcast = now; const snap = makeSnapshot(job); snap.recentLines = job.logs.slice(-8).map(l => l.line); progressCallback(snap); } else { progressCallback({ id: job.id, line: clean, stage: job.stage, percent: job.percent }); } }
// Parse progress hints from build-and-flash.sh + upload-release.sh output if (clean.includes("[build]") || clean.includes("[ac-os]")) { if (clean.match(/Building kernel|bzImage|vmlinuz/i)) { job.stage = "kernel"; job.percent = Math.max(job.percent, 55); } else if (clean.match(/initramfs|cpio|lz4|Repacking|Copying firmware/i)) { job.stage = "initramfs"; job.percent = Math.max(job.percent, 30); } else if (clean.match(/Building binary|Built:|ac-native|gcc|musl/i)) { job.stage = "binary"; job.percent = Math.max(job.percent, 10); } } // Nix build progress hints if (clean.match(/copying path|building.*\.drv|fetching.*narinfo/i)) { if (job.stage !== "nix-upload") { job.stage = "nix-build"; job.percent = Math.max(job.percent, 65); } } if (clean.match(/SMOKE TEST|smoke_test|qemu/i)) { job.stage = "smoke-test"; job.percent = Math.max(job.percent, 80); } if (clean.match(/Uploading|uploaded:/i)) { job.stage = "upload"; job.percent = Math.max(job.percent, 90); } if (clean.includes("Release published")) { job.stage = "done"; job.percent = 100; }}
function makeSnapshot(job, opts = {}) { const { includeLogs = false, tail = 200 } = opts; const snap = { id: job.id, ref: job.ref, status: job.status, stage: job.stage, percent: job.percent, flags: job.flags, createdAt: job.createdAt, startedAt: job.startedAt, updatedAt: job.updatedAt, finishedAt: job.finishedAt, exitCode: job.exitCode, error: job.error, buildName: job.buildName || null, variant: job.variant || "c", commitMsg: job.commitMsg || null, logCount: job.logs.length, elapsedMs: job.startedAt ? (job.finishedAt ? Date.parse(job.finishedAt) : Date.now()) - Date.parse(job.startedAt) : 0, }; if (includeLogs) { const start = Math.max(0, job.logs.length - Math.max(0, tail)); snap.logs = job.logs.slice(start); } return snap;}
// Determine build-and-flash.sh flags based on which paths changed.// Never skip binary: AC_BUILD_NAME and AC_GIT_HASH are compiled into the// binary via CFLAGS and change on every commit. The Makefile's CFLAGS// signature check (`.cflags` md5) handles incremental rebuilds efficiently —// only object files are recompiled when flags change, not the full kernel.// Skipping the binary causes version string mismatch (device shows stale name).function buildFlagsFor(changedPaths = "") { return [];}
// Symlink fedac/native/build → CACHE_DIR so kernel object files survive// the rsync --delete that happens on every deploy/push sync.async function setupBuildCache() { await fs.mkdir(CACHE_DIR, { recursive: true }); const buildLink = path.join(NATIVE_DIR, "build"); let stat; try { stat = await fs.lstat(buildLink); } catch { await fs.symlink(CACHE_DIR, buildLink); return; } if (stat.isSymbolicLink()) return; if (stat.isDirectory()) { try { await fs.rename(buildLink, CACHE_DIR); } catch { await fs.rm(buildLink, { recursive: true, force: true }); } } await fs.symlink(CACHE_DIR, buildLink);}
function wireStream(job, proc, streamName) { let pending = ""; const s = streamName === "stdout" ? proc.stdout : proc.stderr; s.on("data", (chunk) => { pending += chunk.toString(); let idx; while ((idx = pending.indexOf("\n")) >= 0) { addLogLine(job, streamName, pending.slice(0, idx)); pending = pending.slice(idx + 1); } }); s.on("end", () => { if (pending) addLogLine(job, streamName, pending); });}
async function runPhase(job, label, cmd, args, cwd, extraEnv = {}) { job.stage = label; job.updatedAt = nowISO(); return new Promise((resolve, reject) => { const proc = spawn(cmd, args, { cwd, env: { ...process.env, TERM: "dumb", CLICOLOR: "0", FORCE_COLOR: "0", ...extraEnv, }, stdio: ["ignore", "pipe", "pipe"], }); job.process = proc; job.pid = proc.pid; wireStream(job, proc, "stdout"); wireStream(job, proc, "stderr"); proc.on("error", reject); proc.on("close", (code) => { job.process = null; job.exitCode = code; if (code !== 0) reject(new Error(`${label} failed (exit ${code})`)); else resolve(); }); });}
async function runBuildJob(job) { try { job.status = "running"; job.startedAt = nowISO(); job.percent = 0;
const repoDir = path.resolve(NATIVE_DIR, "../..");
// Determine variant: "c" (default), "cl", "nix", "both", or "all" const variant = job.variant || "c"; const buildC = variant === "c" || variant === "both" || variant === "all"; const buildCL = variant === "cl" || variant === "both" || variant === "all"; const buildNix = variant === "nix" || variant === "all"; const needsDockerBuild = buildC || buildCL;
// Preserve caller-specified ref so we can honor it after the sync. // If a specific ref was POSTed (e.g. "45a3ae7e3"), we must check it out // AFTER fetching — otherwise preflight silently rewrites job.ref to // whatever origin/main happens to be at sync time (which was masking a // stale-clone bug: fetches failing silently left old commits pinned). const requestedRef = (job.ref && job.ref !== "unknown") ? job.ref : null;
// Preflight: hard-sync build repo and refuse conflicted/dirty native/Nix trees. addLogLine(job, "stdout", "Preflight: syncing native git checkout..."); await runPhase(job, "preflight-sync", "bash", ["-lc", [ "set -euo pipefail", // Make fetch failures LOUD — silent `|| true` previously hid auth / // network issues and let the builder ship whatever stale commit was // already checked out, which is exactly the bug we're fixing. `git fetch origin ${NATIVE_BRANCH} --quiet`, `git checkout -f ${NATIVE_BRANCH} --quiet || true`, `if git rev-parse --verify origin/${NATIVE_BRANCH} >/dev/null 2>&1; then`, ` git reset --hard origin/${NATIVE_BRANCH} --quiet`, "fi", // Honor caller-specified ref. Skip the direct-SHA fetch — tangled/ // knot rejects short-SHA fetches over the wire (treats them as // missing refs), which caused preflight-sync failures for every // 9-char abbreviated ref. Since `fetch origin <branch>` above // already brings in all commits reachable from main, a local // `git checkout <ref>` resolves abbreviations against the local // object db — works for both full and short SHAs. requestedRef ? `git checkout -f ${requestedRef} --quiet` : "", "git clean -fdq -- fedac/native fedac/nixos", ].filter(Boolean).join("\n")], repoDir);
const syncedRef = await runSync("git", ["rev-parse", "HEAD"], repoDir); // Only overwrite job.ref with HEAD when the caller didn't specify one — // otherwise we'd hide a mismatch if the checkout silently landed on a // different commit than requested. if (syncedRef && !requestedRef) job.ref = syncedRef; if (requestedRef && syncedRef && !syncedRef.startsWith(requestedRef) && !requestedRef.startsWith(syncedRef)) { throw new Error( `Preflight checkout landed at ${syncedRef}, but caller requested ${requestedRef}. Aborting to avoid shipping the wrong commit.`, ); }
const trackedDirty = await runSync( "git", ["status", "--porcelain", "--untracked-files=no", "--", "fedac/native", "fedac/nixos"], repoDir, ); if (trackedDirty) { throw new Error( `Refusing native build: fedac/native or fedac/nixos tree is dirty after sync:\n${trackedDirty}`, ); }
const unresolved = await runSync( "git", ["diff", "--name-only", "--diff-filter=U", "--", "fedac/native", "fedac/nixos"], repoDir, ); if (unresolved) { throw new Error( `Refusing native build: unresolved merge conflict(s): ${unresolved}`, ); }
const conflictMarkers = await runSync( "bash", [ "-lc", "grep -nE '^(<<<<<<<|=======|>>>>>>>)|Updated upstream|Stashed changes' fedac/native/initramfs/init 2>/dev/null || true", ], repoDir, ); if (conflictMarkers) { throw new Error( `Refusing native build: conflict markers detected in initramfs/init:\n${conflictMarkers}`, ); }
// Parse-check init script explicitly so syntax issues fail before kernel compile/upload. await runPhase( job, "preflight-init", "bash", ["-lc", "set -euo pipefail\nsh -n fedac/native/initramfs/init"], repoDir, );
// Auto-cleanup: prune Docker + old build artifacts to prevent disk-full failures. addLogLine(job, "stdout", "Preflight: freeing disk space..."); await runPhase(job, "preflight-cleanup", "bash", ["-lc", [ "set -euo pipefail", "docker system prune -f --volumes 2>/dev/null | tail -1 || true", "rm -rf /tmp/oven-vmlinuz-* /tmp/ac-build-* 2>/dev/null || true", "df -h / | tail -1", ].join("\n")], repoDir);
// Resolve ref from git HEAD if manual trigger didn't provide one if (!job.ref || job.ref === "unknown") { const headRef = await runSync("git", ["rev-parse", "HEAD"], repoDir); if (headRef) job.ref = headRef; }
const buildName = await runSync("bash", ["scripts/build-name.sh"], NATIVE_DIR) || `oven-${job.ref.slice(0, 7)}`; const buildGitHash = await runSync("git", ["rev-parse", "--short", "HEAD"], repoDir) || job.ref.slice(0, 9); const buildTs = new Date().toISOString().slice(0, 16); const commitMsg = await runSync("git", ["log", "-1", "--format=%s", job.ref], repoDir) || ""; job.buildName = buildName; job.buildGitHash = buildGitHash; job.buildTs = buildTs; job.commitMsg = commitMsg; const vmlinuzOut = `/tmp/oven-vmlinuz-${job.id}`;
// Pre-build: prune Docker only when a Docker-backed variant is needed. if (needsDockerBuild) { addLogLine(job, "stdout", "Pre-build: Pruning Docker artifacts..."); try { await runPhase(job, "prune", "bash", ["-c", "docker container prune -f && docker image prune -af --filter until=2h && docker builder prune -af --filter until=30m && docker volume prune -f", ], repoDir); const dfOut = await runSync("bash", ["-c", "df --output=avail / | tail -1"], repoDir); const availKB = parseInt(dfOut, 10) || 0; const availGB = availKB / 1048576; addLogLine(job, "stdout", ` Disk: ${availGB.toFixed(1)}GB free`); if (availGB < 10) { addLogLine(job, "stderr", ` WARNING: Only ${availGB.toFixed(1)}GB free — running full prune...`); await runPhase(job, "emergency-prune", "bash", ["-c", "docker system prune -af --volumes", ], repoDir); } } catch { addLogLine(job, "stdout", " Prune skipped (non-fatal)"); }
// Phase 1: Docker image build (cached layers = fast) addLogLine(job, "stdout", "Phase 1: Building Docker image..."); await runPhase(job, "docker-build", "docker", [ "build", "-t", "ac-os-builder", "-f", path.join(repoDir, "fedac/native/Dockerfile.builder"), repoDir, ], repoDir);
job.percent = 30; }
const uploadScript = path.join(NATIVE_DIR, "scripts/upload-release.sh"); const uploadEnv = { DO_SPACES_KEY: process.env.DO_SPACES_KEY || process.env.ART_SPACES_KEY || "", DO_SPACES_SECRET: process.env.DO_SPACES_SECRET || process.env.ART_SPACES_SECRET || "", AC_BUILD_NAME: buildName, AC_GIT_HASH: buildGitHash, AC_BUILD_TS: buildTs, AC_COMMIT_MSG: commitMsg, };
// ── C variant ── if (buildC) { addLogLine(job, "stdout", "Phase 2: Compiling C kernel in Docker..."); const cidFile = `/tmp/oven-cid-${job.id}`; await runPhase(job, "build", "bash", ["-c", [ `CID=$(docker create -e AC_BUILD_NAME=${buildName} -e AC_GIT_HASH=${buildGitHash} -e AC_BUILD_TS=${buildTs} -v ac-os-ccache:/ccache -v ac-os-kbuild:/kernel-build ac-os-builder)`, `echo $CID > ${cidFile}`, `docker start -a $CID`, ].join(" && ")], repoDir);
job.percent = 75;
addLogLine(job, "stdout", "Phase 3: Extracting C kernel + ISO + slim kernel + initramfs..."); const cid = (await fs.readFile(cidFile, "utf8")).trim(); const isoOut = `/tmp/oven-iso-${job.id}`; const slimOut = `/tmp/oven-vmlinuz-slim-${job.id}`; const initramfsOut = `/tmp/oven-initramfs-${job.id}`; await runPhase(job, "extract", "bash", ["-c", [ `docker cp ${cid}:/tmp/ac-build/vmlinuz ${vmlinuzOut}`, `docker cp ${cid}:/tmp/ac-build/ac-os.iso ${isoOut} 2>/dev/null || docker cp ${cid}:/out/ac-os.iso ${isoOut} 2>/dev/null || true`, `docker cp ${cid}:/tmp/ac-build/vmlinuz-slim ${slimOut} 2>/dev/null || docker cp ${cid}:/out/vmlinuz-slim ${slimOut} 2>/dev/null || true`, `docker cp ${cid}:/tmp/ac-build/initramfs.cpio.gz ${initramfsOut} 2>/dev/null || docker cp ${cid}:/out/initramfs.cpio.gz ${initramfsOut} 2>/dev/null || true`, `ls -lh ${slimOut} ${initramfsOut} 2>/dev/null || echo "WARNING: slim/initramfs not extracted"`, `docker rm ${cid} >/dev/null`, ].join("; ")], repoDir);
job.percent = 80;
addLogLine(job, "stdout", "Phase 4: Uploading C variant to CDN..."); const uploadDir = `/tmp/oven-upload-${job.id}`; const vmlinuzUpload = `${uploadDir}/vmlinuz`; const isoUpload = `${uploadDir}/ac-os.iso`; const slimUpload = `${uploadDir}/vmlinuz-slim`; const initramfsUpload = `${uploadDir}/initramfs.cpio.gz`; await fs.mkdir(uploadDir, { recursive: true }); await fs.rename(vmlinuzOut, vmlinuzUpload); try { await fs.rename(isoOut, isoUpload); } catch {} try { await fs.rename(slimOut, slimUpload); } catch {} try { await fs.rename(initramfsOut, initramfsUpload); } catch {} // upload-release.sh auto-detects sibling files (vmlinuz-slim, initramfs.cpio.gz, ac-os.iso) await runPhase(job, "upload", "bash", [uploadScript, vmlinuzUpload], NATIVE_DIR, uploadEnv);
try { await fs.rm(uploadDir, { recursive: true }); } catch {} try { await fs.unlink(cidFile); } catch {} addLogLine(job, "stdout", "C variant uploaded successfully"); }
job.percent = buildCL ? 50 : 90;
// ── CL variant ── if (buildCL) { addLogLine(job, "stdout", `Phase ${buildC ? 5 : 2}: Compiling CL kernel in Docker...`); const clCidFile = `/tmp/oven-cl-cid-${job.id}`; const clVmlinuzOut = `/tmp/oven-cl-vmlinuz-${job.id}`;
await runPhase(job, "cl-build", "bash", ["-c", [ `CID=$(docker create -e AC_BUILD_NAME=${buildName} -e AC_GIT_HASH=${buildGitHash} -e AC_BUILD_TS=${buildTs} -e AC_BUILD_VARIANT=cl -e AC_BUILD_LISP=1 ac-os-builder)`, `echo $CID > ${clCidFile}`, `docker start -a $CID`, ].join(" && ")], repoDir);
job.percent = buildC ? 85 : 75;
addLogLine(job, "stdout", "Extracting CL kernel..."); const clCid = (await fs.readFile(clCidFile, "utf8")).trim(); const clIsoOut = `/tmp/oven-cl-iso-${job.id}`; await runPhase(job, "cl-extract", "bash", ["-c", `docker cp ${clCid}:/tmp/ac-build/vmlinuz ${clVmlinuzOut} && docker cp ${clCid}:/tmp/ac-build/ac-os.iso ${clIsoOut} 2>/dev/null; docker rm ${clCid} >/dev/null` ], repoDir);
job.percent = buildC ? 90 : 80;
addLogLine(job, "stdout", "Uploading CL variant to CDN..."); const clUploadDir = `/tmp/oven-cl-upload-${job.id}`; const clVmlinuzUpload = `${clUploadDir}/vmlinuz`; const clIsoUpload = `${clUploadDir}/ac-os.iso`; await fs.mkdir(clUploadDir, { recursive: true }); await fs.rename(clVmlinuzOut, clVmlinuzUpload); try { await fs.rename(clIsoOut, clIsoUpload); } catch {} await runPhase(job, "cl-upload", "bash", [uploadScript, clVmlinuzUpload], NATIVE_DIR, { ...uploadEnv, OTA_CHANNEL: "cl", });
try { await fs.rm(clUploadDir, { recursive: true }); } catch {} try { await fs.unlink(clCidFile); } catch {} addLogLine(job, "stdout", "CL variant uploaded successfully"); }
// ── NixOS variant: build directly on host with nix (no Docker) ── if (buildNix) { const nixosDir = path.resolve(NATIVE_DIR, "../nixos"); const nixHomeDir = `/tmp/oven-nix-home-${job.id}`; const nixUploadDir = `/tmp/oven-nix-upload-${job.id}`; const nixBin = await resolveBinary("nix", NIX_BIN_CANDIDATES, nixosDir); if (!nixBin) { throw new Error( "Nix binary not found on oven host. Checked PATH and: " + uniqueNonEmpty(NIX_BIN_CANDIDATES).join(", "), ); } const nixGcBin = await resolveBinary( "nix-collect-garbage", [ path.join(path.dirname(nixBin), "nix-collect-garbage"), ...NIX_GC_CANDIDATES, ], nixosDir, ); await fs.mkdir(path.join(nixHomeDir, ".cache", "nix"), { recursive: true }); const nixEnv = { HOME: nixHomeDir, XDG_CACHE_HOME: path.join(nixHomeDir, ".cache"), NIX_CONFIG: "experimental-features = nix-command flakes\nwarn-dirty = false", AC_NIX_NATIVE_SRC: NATIVE_DIR, PATH: uniqueNonEmpty([ path.dirname(nixBin), nixGcBin ? path.dirname(nixGcBin) : "", process.env.PATH || "", ]).join(":"), }; addLogLine(job, "stdout", `Phase N: using nix at ${nixBin}`);
try { // Preflight: garbage collect old nix store entries addLogLine(job, "stdout", "Phase N: NixOS — cleaning Nix store..."); if (nixGcBin) { try { await runPhase( job, "nix-gc", nixGcBin, ["--delete-older-than", "3d"], nixosDir, nixEnv, ); } catch {} } else { addLogLine(job, "stdout", "Phase N: skipping Nix GC — nix-collect-garbage not found"); }
addLogLine(job, "stdout", "Phase N: NixOS — building image with Nix..."); job.stage = "nix-build"; job.percent = Math.max(job.percent, 60); if (progressCallback) progressCallback(makeSnapshot(job));
// fedac/nixos reads AC_NIX_NATIVE_SRC from the host env to import fedac/native. // Build the raw NixOS disk image. await runPhase(job, "nix-build", nixBin, [ "build", ".#usb-image", "--impure", "--no-link", "--print-out-paths", ], nixosDir, nixEnv);
job.percent = Math.max(job.percent, 85);
// Reuse the stdout path from the build phase instead of invoking nix twice. const nixOutResult = [...job.logs] .reverse() .find((entry) => entry.stream === "stdout" && /^\/nix\/store\/.+$/.test(entry.line || "") )?.line || ""; if (!nixOutResult) { throw new Error("NixOS build finished without returning an output path"); }
// Find the raw disk image in the output directory. const imgPath = await runSync( "bash", ["-lc", "find \"$1\" -name '*.img' -type f | head -1", "_", nixOutResult], nixosDir, );
if (!imgPath) { throw new Error("NixOS build produced no image file"); }
addLogLine(job, "stdout", `NixOS image: ${imgPath}`);
// Copy to upload directory await fs.mkdir(nixUploadDir, { recursive: true }); const nixImgUpload = path.join(nixUploadDir, "ac-os-nixos.img"); const nixConfigUpload = path.join(nixUploadDir, "config.json"); await fs.copyFile(imgPath, nixImgUpload); await fs.writeFile( nixConfigUpload, `${JSON.stringify({ handle: "", piece: "notepat", sub: "", email: "" })}\n`, );
addLogLine(job, "stdout", "Phase N: building media helper image..."); await runPhase(job, "nix-helper-build", "docker", [ "build", "-t", MEDIA_HELPER_IMAGE, "-f", path.join(repoDir, "fedac/native/Dockerfile.flash-helper"), repoDir, ], repoDir, { ...nixEnv, DOCKER_BUILDKIT: "0", });
addLogLine(job, "stdout", "Phase N: appending AC-MAC + ACDATA partitions..."); await runPhase(job, "nix-package", "docker", [ "run", "--rm", "--privileged", "-v", `${nixUploadDir}:/work`, "--entrypoint", "/bin/bash", MEDIA_HELPER_IMAGE, "-lc", "exec /usr/local/bin/ac-os-nixos-image-helper /work/ac-os-nixos.img /work/config.json", ], nixUploadDir, nixEnv);
job.stage = "nix-upload"; job.percent = Math.max(job.percent, 90);
// Upload with nix- channel prefix await runPhase(job, "nix-upload", "bash", [ uploadScript, "--image", nixImgUpload, ], NATIVE_DIR, { ...uploadEnv, OTA_CHANNEL: "nix", });
addLogLine(job, "stdout", "NixOS variant uploaded successfully"); } finally { try { await fs.rm(nixUploadDir, { recursive: true }); } catch {} try { await fs.rm(nixHomeDir, { recursive: true }); } catch {} } }
job.status = "success"; job.stage = "done"; job.percent = 100; job.error = null; job.finishedAt = nowISO(); if (progressCallback) progressCallback(makeSnapshot(job)); } catch (err) { job.finishedAt = nowISO(); job.status = job.status === "cancelled" ? "cancelled" : "failed"; job.stage = job.status; job.error = err.message || String(err); if (progressCallback) progressCallback(makeSnapshot(job)); } finally { await persistNativeBuildRecord(job); if (activeJobId === job.id) activeJobId = null; }}
export async function startNativeBuild(options = {}) { if (activeJobId) { const err = new Error(`Native build already running: ${activeJobId}`); err.code = "NATIVE_BUILD_BUSY"; err.activeJobId = activeJobId; throw err; }
const id = randomUUID().slice(0, 10); const flags = buildFlagsFor(options.changed_paths || ""); const job = { id, ref: options.ref || "unknown", flags, status: "queued", stage: "queued", percent: 0, createdAt: nowISO(), startedAt: null, updatedAt: nowISO(), finishedAt: null, pid: null, process: null, exitCode: null, error: null, changedPaths: options.changed_paths || "", variant: options.variant || "c", logs: [], };
jobs.set(id, job); jobOrder.unshift(id); while (jobOrder.length > MAX_RECENT_JOBS) { const old = jobOrder.pop(); if (old !== activeJobId) jobs.delete(old); } activeJobId = id; runBuildJob(job).catch(() => {}); return makeSnapshot(job);}
export function getNativeBuild(jobId, opts = {}) { const job = jobs.get(jobId); return job ? makeSnapshot(job, opts) : null;}
export function getNativeBuildsSummary() { return { activeJobId, active: activeJobId ? makeSnapshot(jobs.get(activeJobId)) : null, recent: jobOrder .map((id) => jobs.get(id)) .filter(Boolean) .map((j) => makeSnapshot(j)), };}
export function cancelNativeBuild(jobId) { const job = jobs.get(jobId); if (!job) return { ok: false, error: "not found" }; if (job.status !== "running" || !job.process) return { ok: false, error: "not running" }; try { job.process.kill("SIGTERM"); job.status = "cancelled"; return { ok: true }; } catch (err) { return { ok: false, error: err.message }; }}