mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* 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.
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
#!/usr/bin/env node
|
|
// Routes UI package commands through the repo's Node/pnpm wrappers.
|
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { isPidDefinitelyDead } from "../src/shared/pid-alive.ts";
|
|
import { normalizeControlUiBuildInfo } from "../ui/src/build-info-normalizers.ts";
|
|
import { resolveBuildIdentityEnvironment } from "./lib/build-identity.mts";
|
|
import {
|
|
assertRealOutputRoot,
|
|
CONTROL_UI_BUILD_PREFIX,
|
|
controlUiBuildSiblingPid,
|
|
} from "./lib/output-root-guard.mjs";
|
|
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
|
|
import { resolveNodePackageBin } from "./run-node-package-bin.mts";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(here, "..");
|
|
const uiDir = path.join(repoRoot, "ui");
|
|
const requireFromUi = createRequire(path.join(uiDir, "package.json"));
|
|
|
|
const FORWARDED_SIGNAL_KILL_GRACE_MS = 250;
|
|
const PUBLISH_RENAME_DELAYS_MS = [100, 200, 400, 800, 1600];
|
|
const PUBLISH_RENAME_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
|
|
type UiBuildEnvironmentSources = {
|
|
env?: NodeJS.ProcessEnv;
|
|
now?: () => Date;
|
|
readBuildInfo?: () => unknown;
|
|
readGitCommit?: () => string | null;
|
|
readPackageVersion?: () => string | null;
|
|
};
|
|
|
|
function readCurrentGitCommit(): string | null {
|
|
const result = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
}
|
|
|
|
function readCurrentPackageVersion(): string | null {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as {
|
|
version?: unknown;
|
|
};
|
|
return typeof parsed.version === "string" && parsed.version.trim()
|
|
? parsed.version.trim()
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readExistingBuildInfo(): unknown {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(path.join(repoRoot, "dist/build-info.json"), "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Reuse a matching runtime identity for a standalone rebuild of the bundled UI. */
|
|
export function resolveUiBuildEnvironment(
|
|
sources: UiBuildEnvironmentSources = {},
|
|
): NodeJS.ProcessEnv {
|
|
const env = sources.env ?? process.env;
|
|
const buildEnv = resolveBuildIdentityEnvironment({
|
|
commitLabel: "Control UI build commit",
|
|
env,
|
|
now: sources.now,
|
|
readGitCommit: sources.readGitCommit ?? readCurrentGitCommit,
|
|
});
|
|
if (env.OPENCLAW_BUILD_TIMESTAMP?.trim() || env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim()) {
|
|
return buildEnv;
|
|
}
|
|
|
|
const existing = normalizeControlUiBuildInfo((sources.readBuildInfo ?? readExistingBuildInfo)());
|
|
const version = (sources.readPackageVersion ?? readCurrentPackageVersion)();
|
|
const release = env.OPENCLAW_CONTROL_UI_RELEASE_BUILD?.trim() === "1";
|
|
if (
|
|
existing.buildId === "dev" ||
|
|
!existing.builtAt ||
|
|
existing.commit !== buildEnv.GIT_COMMIT ||
|
|
existing.version !== version ||
|
|
existing.release !== release
|
|
) {
|
|
return buildEnv;
|
|
}
|
|
return {
|
|
...buildEnv,
|
|
OPENCLAW_BUILD_TIMESTAMP: existing.builtAt,
|
|
OPENCLAW_CONTROL_UI_BUILD_ID: existing.buildId,
|
|
};
|
|
}
|
|
|
|
type UiSpawnCall = ReturnType<typeof createPnpmRunnerSpawnSpec>;
|
|
|
|
type UiSpawnParams = {
|
|
comSpec?: string;
|
|
cwd?: string;
|
|
nodeExecPath?: string;
|
|
npmExecPath?: string;
|
|
platform?: NodeJS.Platform;
|
|
};
|
|
|
|
const hasErrorCode = (error: unknown, code: string): boolean =>
|
|
error instanceof Error && "code" in error && error.code === code;
|
|
|
|
function usage(): void {
|
|
// keep this tiny; it's invoked from npm scripts too
|
|
process.stderr.write("Usage: node scripts/ui.js <install|dev|build|test> [...args]\n");
|
|
}
|
|
|
|
function resolveSpawnCall(
|
|
cmd: string,
|
|
args: string[],
|
|
envOverride?: NodeJS.ProcessEnv,
|
|
params: UiSpawnParams = {},
|
|
): UiSpawnCall {
|
|
const options: UiSpawnCall["options"] = {
|
|
cwd: params.cwd ?? uiDir,
|
|
stdio: "inherit",
|
|
env: envOverride ?? process.env,
|
|
shell: false,
|
|
detached: undefined,
|
|
windowsVerbatimArguments: undefined,
|
|
};
|
|
|
|
return {
|
|
command: cmd,
|
|
args,
|
|
options,
|
|
};
|
|
}
|
|
|
|
export function resolvePnpmSpawnCall(
|
|
pnpmArgs: string[],
|
|
envOverride?: NodeJS.ProcessEnv,
|
|
params: UiSpawnParams = {},
|
|
): UiSpawnCall {
|
|
return createPnpmRunnerSpawnSpec({
|
|
...params,
|
|
cwd: params.cwd ?? uiDir,
|
|
env: envOverride ?? process.env,
|
|
pnpmArgs,
|
|
stdio: "inherit",
|
|
});
|
|
}
|
|
|
|
function runSpawnCall(spawnCall: UiSpawnCall, label: string): void {
|
|
const { command, args: spawnArgs, options } = spawnCall;
|
|
let child;
|
|
try {
|
|
child = spawn(command, spawnArgs, options);
|
|
} catch (err) {
|
|
console.error(`Failed to launch ${label}:`, err);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
const forwardedSignals = ["SIGTERM", "SIGHUP"] as const;
|
|
let forwardedSignal: (typeof forwardedSignals)[number] | null = null;
|
|
let forwardedSignalPids: number[] = [];
|
|
let forwardedSignalTreeComplete = false;
|
|
let childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
let forcedSignalCleanup = false;
|
|
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let forwardedSignalDrainTimer: ReturnType<typeof setInterval> | null = null;
|
|
const clearForwardedSignalTimers = () => {
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer);
|
|
forceKillTimer = null;
|
|
}
|
|
if (forwardedSignalDrainTimer) {
|
|
clearInterval(forwardedSignalDrainTimer);
|
|
forwardedSignalDrainTimer = null;
|
|
}
|
|
};
|
|
const finishForwardedSignal = () => {
|
|
if (!forwardedSignal || !childExit) {
|
|
return;
|
|
}
|
|
cleanupSignalHandlers();
|
|
const interrupted =
|
|
childExit.signal ??
|
|
(forcedSignalCleanup ? "SIGKILL" : !forwardedSignalTreeComplete ? forwardedSignal : null);
|
|
if (interrupted) {
|
|
process.kill(process.pid, interrupted);
|
|
return;
|
|
}
|
|
// A returned child and quiescent captured tree acknowledge this stop.
|
|
// Raw signal death and forced cleanup cannot make that same promise.
|
|
process.exit(forwardedSignal === "SIGTERM" ? 143 : 129);
|
|
};
|
|
const waitForForwardedSignalChildren = () => {
|
|
if (!forwardedSignal || !childExit || processTreeIsAlive(forwardedSignalPids)) {
|
|
return;
|
|
}
|
|
finishForwardedSignal();
|
|
};
|
|
// Keep UI dev children in the foreground process group for native TTY
|
|
// resize/job-control behavior. Forward wrapper shutdown signals to the
|
|
// captured child tree instead of using a detached process group.
|
|
const signalHandlers = new Map(
|
|
forwardedSignals.map((signal) => [
|
|
signal,
|
|
() => {
|
|
if (!forwardedSignal) {
|
|
forwardedSignal = signal;
|
|
const tree = collectChildProcessTreePids(child);
|
|
forwardedSignalPids = tree.pids;
|
|
forwardedSignalTreeComplete = tree.complete;
|
|
signalProcessTree(child, signal, forwardedSignalPids);
|
|
forwardedSignalDrainTimer = setInterval(waitForForwardedSignalChildren, 25);
|
|
forceKillTimer = setTimeout(() => {
|
|
if (processTreeIsAlive(forwardedSignalPids)) {
|
|
forcedSignalCleanup = true;
|
|
signalProcessTree(child, "SIGKILL", forwardedSignalPids);
|
|
}
|
|
}, FORWARDED_SIGNAL_KILL_GRACE_MS);
|
|
forceKillTimer.unref?.();
|
|
}
|
|
},
|
|
]),
|
|
);
|
|
const cleanupSignalHandlers = () => {
|
|
for (const [signal, handler] of signalHandlers) {
|
|
process.off(signal, handler);
|
|
}
|
|
clearForwardedSignalTimers();
|
|
};
|
|
for (const [signal, handler] of signalHandlers) {
|
|
process.on(signal, handler);
|
|
}
|
|
|
|
child.on("error", (err) => {
|
|
cleanupSignalHandlers();
|
|
console.error(`Failed to launch ${label}:`, err);
|
|
process.exit(1);
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
childExit = { code, signal };
|
|
if (forwardedSignal) {
|
|
forwardedSignalPids = forwardedSignalPids.filter((pid) => pid !== child.pid);
|
|
waitForForwardedSignalChildren();
|
|
return;
|
|
}
|
|
cleanupSignalHandlers();
|
|
if (signal) {
|
|
process.kill(process.pid, signal);
|
|
return;
|
|
}
|
|
if (code !== 0) {
|
|
process.exit(code ?? 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
function collectChildProcessTreePids(child: ChildProcess): { pids: number[]; complete: boolean } {
|
|
if (process.platform === "win32" || typeof child.pid !== "number") {
|
|
return { pids: typeof child.pid === "number" ? [child.pid] : [], complete: false };
|
|
}
|
|
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
|
if (ps.status !== 0) {
|
|
return { pids: [child.pid], complete: false };
|
|
}
|
|
const childrenByParent = new Map<number, number[]>();
|
|
let complete = true;
|
|
let childFound = false;
|
|
for (const line of ps.stdout.split("\n")) {
|
|
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
|
|
if (!match) {
|
|
if (line.trim()) {
|
|
complete = false;
|
|
}
|
|
continue;
|
|
}
|
|
const pid = Number(match[1]);
|
|
const ppid = Number(match[2]);
|
|
childFound ||= pid === child.pid;
|
|
const siblings = childrenByParent.get(ppid) ?? [];
|
|
siblings.push(pid);
|
|
childrenByParent.set(ppid, siblings);
|
|
}
|
|
const pids = [child.pid];
|
|
for (const parentPid of pids) {
|
|
for (const pid of childrenByParent.get(parentPid) ?? []) {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
return { pids: [...new Set(pids)], complete: complete && childFound };
|
|
}
|
|
|
|
function processTreeIsAlive(pids: number[]): boolean {
|
|
return pids.some((pid) => !isPidDefinitelyDead(pid));
|
|
}
|
|
|
|
function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals, pids: number[]): void {
|
|
if (process.platform === "win32") {
|
|
child.kill(signal);
|
|
return;
|
|
}
|
|
if (pids.length === 0) {
|
|
child.kill(signal);
|
|
return;
|
|
}
|
|
for (const pid of pids.toReversed()) {
|
|
try {
|
|
process.kill(pid, signal);
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ESRCH")) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type UiSpawnResult = { status: number | null; signal: NodeJS.Signals | null };
|
|
|
|
function runSpawnCallSync(spawnCall: UiSpawnCall, label: string): UiSpawnResult {
|
|
const { command, args: spawnArgs, options } = spawnCall;
|
|
try {
|
|
return spawnSync(command, spawnArgs, options);
|
|
} catch (err) {
|
|
console.error(`Failed to launch ${label}:`, err);
|
|
return { status: 1, signal: null };
|
|
}
|
|
}
|
|
|
|
function exitForSpawnResult(result: UiSpawnResult): void {
|
|
if (result.signal) {
|
|
process.kill(process.pid, result.signal);
|
|
return;
|
|
}
|
|
if ((result.status ?? 1) !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
|
|
function removeUiBuildDirectory(directory: string): void {
|
|
try {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
} catch (error) {
|
|
// Preserve the build outcome; a later build can reclaim locked leftovers.
|
|
console.warn(`Could not remove temporary Control UI output ${directory}:`, error);
|
|
}
|
|
}
|
|
|
|
function renameWithRetry(from: string, to: string): void {
|
|
// Windows scanners/indexers can transiently deny freshly written or served trees.
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
fs.renameSync(from, to);
|
|
return;
|
|
} catch (error) {
|
|
const delay = PUBLISH_RENAME_DELAYS_MS[attempt];
|
|
if (
|
|
delay === undefined ||
|
|
!["EPERM", "EACCES", "EBUSY"].some((code) => hasErrorCode(error, code))
|
|
) {
|
|
throw error;
|
|
}
|
|
Atomics.wait(PUBLISH_RENAME_WAIT, 0, 0, delay);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildAndPublishUi(toolCall: UiSpawnCall, env: NodeJS.ProcessEnv): UiSpawnResult {
|
|
const dist = path.join(repoRoot, "dist");
|
|
const output = path.join(dist, "control-ui");
|
|
fs.mkdirSync(dist, { recursive: true });
|
|
if (!fs.existsSync(output)) {
|
|
// An interrupted swap can leave the previous complete build in a retired sibling.
|
|
const retired = fs
|
|
.readdirSync(dist)
|
|
.filter((name) => {
|
|
const pid = controlUiBuildSiblingPid(name);
|
|
return name.endsWith(".retired") && pid !== null && isPidDefinitelyDead(pid);
|
|
})
|
|
.map((name) => path.join(dist, name))
|
|
.toSorted((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs)[0];
|
|
if (retired) {
|
|
renameWithRetry(retired, output);
|
|
}
|
|
}
|
|
for (const name of fs.readdirSync(dist)) {
|
|
const pid = controlUiBuildSiblingPid(name);
|
|
if (pid !== null && isPidDefinitelyDead(pid)) {
|
|
removeUiBuildDirectory(path.join(dist, name));
|
|
}
|
|
}
|
|
const staging = fs.mkdtempSync(path.join(dist, `${CONTROL_UI_BUILD_PREFIX}${process.pid}-`));
|
|
const retired = `${staging}.retired`;
|
|
try {
|
|
const validator = (script: string, ...args: string[]): [UiSpawnCall, string] => [
|
|
resolveSpawnCall(process.execPath, [path.join(here, script), ...args], env, {
|
|
cwd: repoRoot,
|
|
}),
|
|
script,
|
|
];
|
|
const calls: [UiSpawnCall, string][] = [
|
|
[{ ...toolCall, args: [...toolCall.args, "--outDir", staging] }, "Control UI build"],
|
|
validator("check-control-ui-precompressed-assets.mts", staging),
|
|
validator("check-control-ui-performance.mts", "--report-only", "--dist", staging),
|
|
];
|
|
for (const [call, label] of calls) {
|
|
const result = runSpawnCallSync(call, label);
|
|
if (result.signal || result.status !== 0) {
|
|
return result;
|
|
}
|
|
}
|
|
const hadOutput = fs.existsSync(output);
|
|
if (hadOutput) {
|
|
renameWithRetry(output, retired);
|
|
}
|
|
try {
|
|
renameWithRetry(staging, output);
|
|
} catch (error) {
|
|
if (hadOutput) {
|
|
renameWithRetry(retired, output);
|
|
}
|
|
throw new Error("Failed to publish Control UI build; previous output retained.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
removeUiBuildDirectory(retired);
|
|
return { status: 0, signal: null };
|
|
} finally {
|
|
removeUiBuildDirectory(staging);
|
|
}
|
|
}
|
|
|
|
function depsInstalled(kind: "build" | "test"): boolean {
|
|
try {
|
|
requireFromUi.resolve("vite");
|
|
requireFromUi.resolve("dompurify");
|
|
if (kind === "test") {
|
|
requireFromUi.resolve("vitest");
|
|
requireFromUi.resolve("@vitest/browser-playwright");
|
|
requireFromUi.resolve("playwright");
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveScriptAction(action: string): [tool: "vite" | "vitest", ...args: string[]] | null {
|
|
if (action === "dev") {
|
|
return ["vite"];
|
|
}
|
|
if (action === "build") {
|
|
return ["vite", "build"];
|
|
}
|
|
if (action === "test") {
|
|
return ["vitest", "run", "--config", "vitest.config.ts"];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function runUiCli(argv: string[] = process.argv.slice(2)): void {
|
|
const [action, ...rest] = argv;
|
|
if (!action) {
|
|
usage();
|
|
process.exit(2);
|
|
}
|
|
|
|
const script = resolveScriptAction(action);
|
|
if (action !== "install" && !script) {
|
|
usage();
|
|
process.exit(2);
|
|
}
|
|
if (action === "build") {
|
|
assertRealOutputRoot(path.join(repoRoot, "dist"));
|
|
assertRealOutputRoot(path.join(repoRoot, "dist/control-ui"));
|
|
}
|
|
|
|
if (action === "install") {
|
|
runSpawnCall(resolvePnpmSpawnCall(["install", ...rest]), "pnpm");
|
|
return;
|
|
}
|
|
if (!script) {
|
|
return;
|
|
}
|
|
|
|
const noPnpmBuild = action === "build" && process.env.OPENCLAW_BUILD_ALL_NO_PNPM === "1";
|
|
if (!noPnpmBuild && !depsInstalled(action === "test" ? "test" : "build")) {
|
|
exitForSpawnResult(runSpawnCallSync(resolvePnpmSpawnCall(["install"]), "pnpm"));
|
|
}
|
|
|
|
const [tool, ...args] = script;
|
|
const env = action === "build" ? resolveUiBuildEnvironment() : process.env;
|
|
const toolCall = resolveSpawnCall(
|
|
process.execPath,
|
|
[resolveNodePackageBin(tool, requireFromUi), ...args, ...rest],
|
|
env,
|
|
);
|
|
if (action === "build") {
|
|
exitForSpawnResult(
|
|
rest.some((arg) => arg === "--help" || arg === "-h")
|
|
? runSpawnCallSync(toolCall, "Control UI build")
|
|
: buildAndPublishUi(toolCall, env),
|
|
);
|
|
return;
|
|
}
|
|
|
|
runSpawnCall(toolCall, tool);
|
|
}
|
|
|
|
function resolveDirectExecutionPath(
|
|
entry: string,
|
|
realpath: (filePath: string) => string = fs.realpathSync.native,
|
|
): string {
|
|
const resolved = path.resolve(entry);
|
|
try {
|
|
return realpath(resolved);
|
|
} catch {
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
export function isDirectScriptExecution(
|
|
entry: string | undefined = process.argv[1],
|
|
scriptPath: string = fileURLToPath(import.meta.url),
|
|
realpath: (filePath: string) => string = fs.realpathSync.native,
|
|
): boolean {
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
return (
|
|
resolveDirectExecutionPath(entry, realpath) === resolveDirectExecutionPath(scriptPath, realpath)
|
|
);
|
|
}
|
|
|
|
const isDirectExecution = isDirectScriptExecution();
|
|
|
|
if (isDirectExecution) {
|
|
runUiCli();
|
|
}
|