openclaw/scripts/lib/output-root-guard.mjs
Peter Steinberger 4157ed60ff
fix(ui): stop serving a failed Control UI build on the next Gateway start (#161438)
* fix(ui): stop serving a failed Control UI build on the next Gateway start

Rolldown writes the complete bundle, runs the writeBundle hook, and only
then reports aggregated resolve errors and exits non-zero. scripts/ui.mts
built straight into the served dist/control-ui and reused the runtime build
identity, so a failed build left a correctly stamped tree that the Gateway's
asset health check accepted as ready on the next start (observed: a bare
markdown-it-emoji import broke the Control UI after a gateway-profile worktree
install skipped ui/ dependencies).

scripts/ui.mts now builds into a same-depth dist/control-ui.build-<pid>-*
sibling, runs both validators against it, and renames it into place only on
success, restoring the previous output if publication fails. Dead-process
leftovers are reclaimed on the next build and tsdown's dist cleaning skips
in-flight staging trees. The gateway worktree-setup workload now installs
./ui... because the Gateway builds the Control UI from source on first start.

* fix(ui): restore the previous Control UI bundle after an interrupted swap

A builder killed between moving dist/control-ui aside and renaming its staged build into place left the previous complete bundle only in a dead-process .retired sibling, which the next build's leftover cleanup deleted. The next build now restores the newest dead-process retired bundle when the served path is missing, before reclaiming leftovers, so a failed retry still serves it.

* fix(ui): keep interrupted Control UI builds out of packages

A killed builder cannot reach its cleanup, so a dist/control-ui.build-* staging or retired sibling can survive until the next UI build. Exclude those siblings from the package files list, which also drives the dist inventory, and type the new UI test fixtures for the scripts test lane.

* fix(ui): retry transient Windows denials when publishing the Control UI build

Windows scanners and indexers can briefly deny renaming a freshly written or served directory with EPERM, which failed an otherwise valid rebuild on its first attempt. Every publication rename now retries EPERM, EACCES, and EBUSY on a bounded 100-1600 ms schedule (about 3 s) and keeps restoring the previous bundle when the denial is permanent.
2026-09-30 03:28:17 +00:00

37 lines
1.2 KiB
JavaScript

// Fail-closed output-root guard shared by build and postbuild mutators.
import fs from "node:fs";
export const CONTROL_UI_BUILD_PREFIX = "control-ui.build-";
export function controlUiBuildSiblingPid(name) {
const match =
name.startsWith(CONTROL_UI_BUILD_PREFIX) &&
/^([1-9]\d*)-[\w-]+(?:\.retired)?$/u.exec(name.slice(CONTROL_UI_BUILD_PREFIX.length));
return match ? Number(match[1]) : null;
}
/**
* Throws when a generated output root is a symbolic link. readdir/rm through a
* symlinked root rewrites the link target — observed deleting a live gateway
* build tree — so recursive cleanup and replacement must fail closed here.
*/
export function assertRealOutputRoot(rootPath, params = {}) {
const fsImpl = params.fs ?? fs;
let stat;
try {
stat = fsImpl.lstatSync(rootPath);
} catch (error) {
// Missing roots are normal on a first build; the build recreates them.
if (error?.code === "ENOENT") {
return;
}
throw error;
}
if (!stat.isSymbolicLink()) {
return;
}
throw new Error(
`Build output root "${rootPath}" is a symbolic link; refusing to mutate it. ` +
`Remove the symlink or replace it with a real directory before building.`,
);
}