Files
overlord/plugins/rust-lite-builder/server.js
T

416 lines
15 KiB
JavaScript
Raw Normal View History

import fs from "fs";
import path from "path";
const TARGETS = {
"windows-amd64": { windows: "x86_64-pc-windows-msvc", other: "x86_64-pc-windows-gnu", extension: ".exe" },
"windows-arm64": { windows: "aarch64-pc-windows-msvc", other: "aarch64-pc-windows-gnullvm", extension: ".exe" },
"linux-amd64": { all: "x86_64-unknown-linux-gnu", extension: "" },
"linux-arm64": { all: "aarch64-unknown-linux-gnu", extension: "" },
"darwin-amd64": { all: "x86_64-apple-darwin", extension: "" },
"darwin-arm64": { all: "aarch64-apple-darwin", extension: "" },
};
function rustTarget(platform) {
const target = TARGETS[platform];
if (!target) throw new Error(`Unsupported Rust Lite platform: ${platform}`);
return process.platform === "win32" ? (target.windows || target.all) : (target.other || target.all);
}
function findSource(runtimeRoot) {
const candidates = [
path.join(runtimeRoot, "Overlord-Lite"),
path.join(runtimeRoot, "dist", "Overlord-Lite"),
path.join(runtimeRoot, "..", "Overlord-Lite"),
];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "Cargo.toml"))) return candidate;
}
throw new Error(`Overlord-Lite source not found. Checked: ${candidates.join(", ")}`);
}
async function capture(command, cwd) {
const proc = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with ${exitCode}`);
return stdout.trim();
}
async function streamPipe(stream, onLine) {
const reader = stream.getReader();
const decoder = new TextDecoder();
let pending = "";
while (true) {
const { done, value } = await reader.read();
pending += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = pending.split(/\r?\n/);
pending = lines.pop() || "";
for (const line of lines) if (line.trim()) onLine(line);
if (done) break;
}
if (pending.trim()) onLine(pending);
}
function packageVersion(sourceDir) {
const cargo = fs.readFileSync(path.join(sourceDir, "Cargo.toml"), "utf8");
return cargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1] || "unknown";
}
function formatDuration(milliseconds) {
const seconds = Math.max(0, Math.round(milliseconds / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
async function estimatePackageCount(sourceDir, target, locked) {
const args = [
"cargo",
"tree",
"--target",
target,
"--prefix",
"none",
"--format",
"{p}",
];
if (locked) args.push("--locked");
const output = await capture(args, sourceDir);
const packages = new Set(
output
.split(/\r?\n/)
.map((line) => line.replace(/ \(\*\)$/, "").replace(/ \(proc-macro\)$/, "").trim())
.filter(Boolean),
);
return packages.size;
}
function readBuildHistory(dataDir) {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(dataDir, "build-history.json"), "utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function recordBuildDuration(dataDir, target, durationMs) {
const historyPath = path.join(dataDir, "build-history.json");
const history = readBuildHistory(dataDir);
const previous = Number(history[target]?.durationMs);
const samples = Math.max(0, Number(history[target]?.samples) || 0) + 1;
history[target] = {
durationMs: Number.isFinite(previous)
? Math.round(previous * 0.6 + durationMs * 0.4)
: Math.round(durationMs),
samples,
updatedAt: Date.now(),
};
try {
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
} catch {
// Progress history is optional; a read-only data directory must not fail the build.
}
}
function createTargetProgress(ctx, options) {
const DEFAULT_LINK_BUDGET_MS = 35_000;
const startedAt = Date.now();
const packageStates = new Map();
let linking = false;
let linkingStartedAt = null;
let finalizing = false;
let finished = false;
let lastStatusAt = 0;
let lastLoggedBucket = -1;
function snapshot() {
const elapsedMs = Date.now() - startedAt;
const completedPackages = packageStates.size;
const freshPackages = Array.from(packageStates.values()).filter(Boolean).length;
const packageRatio = options.estimatedPackages > 0
? Math.min(1, completedPackages / options.estimatedPackages)
: 0;
const historicalDurationMs = Number.isFinite(options.historicalDurationMs)
? options.historicalDurationMs
: null;
const linkBudgetMs = historicalDurationMs === null
? DEFAULT_LINK_BUDGET_MS
: Math.max(10_000, historicalDurationMs * 0.4);
const linkingElapsedMs = linkingStartedAt === null ? 0 : Date.now() - linkingStartedAt;
let targetRatio = Math.min(0.7, packageRatio * 0.7);
if (linking) {
const estimatedLinkRatio = Math.min(0.95, linkingElapsedMs / linkBudgetMs);
targetRatio = Math.max(targetRatio, 0.7 + estimatedLinkRatio * 0.28);
}
if (finalizing) targetRatio = Math.max(targetRatio, 0.98);
if (finished) targetRatio = 1;
const overallRatio = (options.targetIndex + targetRatio) / options.targetCount;
let predictedTargetMs = null;
if (historicalDurationMs !== null && historicalDurationMs > 0) {
predictedTargetMs = Math.max(elapsedMs, historicalDurationMs);
}
if (linking && historicalDurationMs === null) {
predictedTargetMs = elapsedMs + Math.max(5_000, linkBudgetMs - linkingElapsedMs);
} else if (!linking && elapsedMs >= 5_000 && packageRatio >= 0.05) {
const rollingPrediction = elapsedMs / packageRatio + linkBudgetMs;
predictedTargetMs = predictedTargetMs === null
? rollingPrediction
: predictedTargetMs * 0.65 + rollingPrediction * 0.35;
}
const etaMs = predictedTargetMs === null || finished
? null
: Math.max(
0,
predictedTargetMs - elapsedMs +
(options.targetCount - options.targetIndex - 1) * predictedTargetMs,
);
return {
elapsedMs,
completedPackages,
freshPackages,
targetRatio,
overallRatio,
etaMs,
};
}
function emit(force = false) {
const now = Date.now();
if (!force && now - lastStatusAt < 1_500) return;
lastStatusAt = now;
const state = snapshot();
const targetPercent = Math.round(state.targetRatio * 100);
const overallPercent = Math.round(state.overallRatio * 100);
const phase = finalizing
? "finalizing artifact"
: linking
? "linking + LTO"
: "compiling";
const pieces = [
`Rust Lite ${options.platform} (${options.targetIndex + 1}/${options.targetCount})`,
`${overallPercent}% overall`,
`${targetPercent}% target`,
phase,
];
if (options.estimatedPackages > 0) {
pieces.push(`${state.completedPackages}/${options.estimatedPackages} crates`);
}
pieces.push(`${formatDuration(state.elapsedMs)} elapsed`);
if (state.etaMs !== null && state.etaMs >= 1_000) {
pieces.push(`ETA ~${formatDuration(state.etaMs)}`);
}
const text = pieces.join(" • ");
ctx.build.status(
text,
Math.min(100, state.overallRatio * 100),
state.etaMs === null ? undefined : Math.ceil(state.etaMs / 1000),
);
const bucket = Math.floor(overallPercent / 10);
if (bucket > lastLoggedBucket && overallPercent > 0) {
lastLoggedBucket = bucket;
ctx.build.output(`Progress: ${text}`);
}
}
return {
startedAt,
noteArtifact(packageId, fresh) {
const previous = packageStates.get(packageId);
packageStates.set(packageId, previous === false ? false : fresh === true);
emit();
},
markLinking() {
linking = true;
linkingStartedAt ??= Date.now();
emit(true);
},
markFinalizing() {
finalizing = true;
emit(true);
},
markFinished() {
finished = true;
emit(true);
return snapshot();
},
emit,
};
}
export default {
setup(ctx) {
ctx.log.info("Rust Lite build provider ready");
},
async onBuild(ctx, payload) {
const sourceDir = findSource(String(payload.runtimeRoot || ""));
const outputDir = path.resolve(String(payload.outputDir || ""));
const targetDir = path.join(ctx.dataDir, "cargo-target");
const platforms = Array.isArray(payload.platforms) ? payload.platforms : [];
if (platforms.length === 0) throw new Error("No Rust Lite platforms were requested");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const cargoVersion = await capture(["cargo", "--version"], sourceDir);
ctx.build.output(`Using ${cargoVersion}`);
ctx.build.output(`Rust Lite source: ${sourceDir}`);
const settings = payload.settings && typeof payload.settings === "object" ? payload.settings : {};
const tlsSpkiPins = Array.isArray(payload.tlsSpkiPins)
? payload.tlsSpkiPins.map(String).filter(Boolean)
: [];
if (tlsSpkiPins.length > 0) {
ctx.build.output(
`TLS identity pinning: enabled (${tlsSpkiPins.length} trusted key${tlsSpkiPins.length === 1 ? "" : "s"})`,
);
} else {
ctx.build.output("WARNING: No TLS SPKI pin is available for this build", "warn");
}
const requestedJobs = Math.trunc(Number(settings.jobs));
const jobs = Number.isFinite(requestedJobs) && requestedJobs > 0
? Math.max(1, Math.min(32, requestedJobs))
: null;
const version = packageVersion(sourceDir);
const artifacts = [];
const history = readBuildHistory(ctx.dataDir);
for (const [targetIndex, platform] of platforms.entries()) {
const target = rustTarget(platform);
const locked = settings.locked !== false;
let estimatedPackages = 0;
try {
estimatedPackages = await estimatePackageCount(sourceDir, target, locked);
} catch (error) {
ctx.build.output(`Package-count estimate unavailable: ${error?.message || error}`, "warn");
}
const progress = createTargetProgress(ctx, {
platform,
targetIndex,
targetCount: platforms.length,
estimatedPackages,
historicalDurationMs: Number(history[target]?.durationMs) || null,
});
progress.emit(true);
ctx.build.output(`\n=== Rust Lite ${platform} (${target}) ===`);
ctx.build.output(
`Release profile: opt-level=z, LTO, 1 codegen unit, stripped symbols, panic=abort`,
);
ctx.build.output(`Cargo jobs: ${jobs === null ? "automatic (host CPU count)" : jobs}`);
if (estimatedPackages > 0) ctx.build.output(`Estimated dependency graph: ${estimatedPackages} crates`);
const args = [
"cargo",
"build",
"--release",
"--target",
target,
"--message-format=json-render-diagnostics",
];
if (jobs !== null) args.push("--jobs", String(jobs));
if (locked) args.push("--locked");
const env = {
...process.env,
CARGO_TARGET_DIR: targetDir,
OVERLORD_LITE_DEFAULT_SERVER: String(payload.serverUrl || ""),
OVERLORD_LITE_DEFAULT_AGENT_TOKEN: String(payload.agentToken || ""),
OVERLORD_LITE_DEFAULT_BUILD_TAG: String(payload.buildTag || ""),
OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS: tlsSpkiPins.join(","),
};
const proc = Bun.spawn(args, {
cwd: sourceDir,
env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const progressTimer = setInterval(() => progress.emit(), 2_000);
let exitCode;
try {
await Promise.all([
streamPipe(proc.stdout, (line) => {
let message;
try {
message = JSON.parse(line);
} catch {
ctx.build.output(line, "info");
return;
}
if (message.reason === "compiler-artifact" && typeof message.package_id === "string") {
progress.noteArtifact(message.package_id, message.fresh === true);
} else if (message.reason === "compiler-message" && message.message?.rendered) {
const level = message.message.level === "error"
? "error"
: message.message.level === "warning"
? "warn"
: "info";
ctx.build.output(String(message.message.rendered).trimEnd(), level);
}
}),
streamPipe(proc.stderr, (line) => {
if (/^\s*Compiling\s+overlord-lite\b/i.test(line)) progress.markLinking();
ctx.build.output(line, /^\s*error(?:\[|:)/i.test(line) ? "error" : "info");
}),
]);
exitCode = await proc.exited;
} finally {
clearInterval(progressTimer);
}
if (exitCode !== 0) {
throw new Error(
`Cargo failed for ${platform} (exit ${exitCode}). Install the ${target} Rust target and its required cross-linker.`,
);
}
progress.markFinalizing();
const extension = TARGETS[platform].extension;
const builtPath = path.join(targetDir, target, "release", `overlord-lite${extension}`);
if (!fs.existsSync(builtPath)) throw new Error(`Cargo did not create ${builtPath}`);
const baseName = String(payload.outputName || "overlord-lite").replace(/[^A-Za-z0-9._-]/g, "") || "overlord-lite";
const filename = `${baseName}-${platform}${extension}`;
fs.copyFileSync(builtPath, path.join(outputDir, filename));
artifacts.push({
name: `Overlord Lite ${platform}`,
filename,
platform,
version,
});
const completed = progress.markFinished();
recordBuildDuration(ctx.dataDir, target, completed.elapsedMs);
const compiledPackages = Math.max(0, completed.completedPackages - completed.freshPackages);
ctx.build.output(
`Completed ${platform} in ${formatDuration(completed.elapsedMs)} ` +
`(${completed.freshPackages} cached, ${compiledPackages} compiled crates)`,
"success",
);
ctx.build.output(`Artifact ready: ${filename}`, "success");
}
return { artifacts };
},
rpc: {
async status(_ctx, _params) {
let cargo = null;
try {
cargo = await capture(["cargo", "--version"], process.cwd());
} catch (error) {
cargo = `Unavailable: ${error?.message || error}`;
}
return {
cargo,
host: `${process.platform}-${process.arch}`,
note: "Cross-target builds require the corresponding rustup target and linker.",
};
},
},
};