diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ee4e37e9..e83528b4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua. - Update: remove dead restore control-ui step that failed on gitignored dist/ output. +- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras. - Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke. - Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70. - Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672) diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 025fad678e..e02720a14f 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -8,6 +8,7 @@ const args = process.argv.slice(2); const env = { ...process.env }; const cwd = process.cwd(); const compiler = "tsdown"; +const compilerArgs = ["exec", compiler, "--no-clean"]; const distRoot = path.join(cwd, "dist"); const distEntry = path.join(distRoot, "/entry.js"); @@ -135,10 +136,9 @@ if (!shouldBuild()) { runNode(); } else { logRunner("Building TypeScript (dist is stale)."); - const pnpmArgs = ["exec", compiler]; const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm"; const buildArgs = - process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs; + process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; const build = spawn(buildCmd, buildArgs, { cwd, env, diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 3e6929f736..8aad9d06fc 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -88,7 +88,10 @@ const STEP_LABELS: Record = { "preflight cleanup": "Cleaning preflight worktree", "deps install": "Installing dependencies", build: "Building", - "ui:build": "Building UI", + "ui:build": "Building UI assets", + "ui:build (post-doctor repair)": "Restoring missing UI assets", + "ui assets verify": "Validating UI assets", + "openclaw doctor entry": "Checking doctor entrypoint", "openclaw doctor": "Running doctor checks", "git rev-parse HEAD (after)": "Verifying update", "global update": "Updating via package manager", diff --git a/src/commands/doctor-ui.ts b/src/commands/doctor-ui.ts index 718ed4a8f6..268738ea13 100644 --- a/src/commands/doctor-ui.ts +++ b/src/commands/doctor-ui.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { RuntimeEnv } from "../runtime.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +import { + resolveControlUiDistIndexHealth, + resolveControlUiDistIndexPathForRoot, +} from "../infra/control-ui-assets.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { note } from "../terminal/note.js"; @@ -21,7 +25,11 @@ export async function maybeRepairUiProtocolFreshness( } const schemaPath = path.join(root, "src/gateway/protocol/schema.ts"); - const uiIndexPath = path.join(root, "dist/control-ui/index.html"); + const uiHealth = await resolveControlUiDistIndexHealth({ + root, + argv1: process.argv[1], + }); + const uiIndexPath = uiHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(root); try { const [schemaStats, uiStats] = await Promise.all([ diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index e9ca9c5106..7b5acbe545 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -3,7 +3,9 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + resolveControlUiDistIndexHealth, resolveControlUiDistIndexPath, + resolveControlUiDistIndexPathForRoot, resolveControlUiRepoRoot, resolveControlUiRootOverrideSync, resolveControlUiRootSync, @@ -190,4 +192,33 @@ describe("control UI assets helpers", () => { await fs.rm(tmp, { recursive: true, force: true }); } }); + + it("reports health for existing control-ui assets at a known root", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const indexPath = resolveControlUiDistIndexPathForRoot(tmp); + await fs.mkdir(path.dirname(indexPath), { recursive: true }); + await fs.writeFile(indexPath, "\n"); + + await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({ + indexPath, + exists: true, + }); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("reports health for missing control-ui assets at a known root", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); + try { + const indexPath = resolveControlUiDistIndexPathForRoot(tmp); + await expect(resolveControlUiDistIndexHealth({ root: tmp })).resolves.toEqual({ + indexPath, + exists: false, + }); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); }); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 4e14be2f18..08e0312c8f 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -5,6 +5,32 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } from "./openclaw-root.js"; +const CONTROL_UI_DIST_PATH_SEGMENTS = ["dist", "control-ui", "index.html"] as const; + +export function resolveControlUiDistIndexPathForRoot(root: string): string { + return path.join(root, ...CONTROL_UI_DIST_PATH_SEGMENTS); +} + +export type ControlUiDistIndexHealth = { + indexPath: string | null; + exists: boolean; +}; + +export async function resolveControlUiDistIndexHealth( + opts: { + root?: string; + argv1?: string; + } = {}, +): Promise { + const indexPath = opts.root + ? resolveControlUiDistIndexPathForRoot(opts.root) + : await resolveControlUiDistIndexPath(opts.argv1 ?? process.argv[1]); + return { + indexPath, + exists: Boolean(indexPath && fs.existsSync(indexPath)), + }; +} + export function resolveControlUiRepoRoot( argv1: string | undefined = process.argv[1], ): string | null { @@ -190,8 +216,9 @@ export async function ensureControlUiAssetsBuilt( runtime: RuntimeEnv = defaultRuntime, opts?: { timeoutMs?: number }, ): Promise { - const indexFromDist = await resolveControlUiDistIndexPath(process.argv[1]); - if (indexFromDist && fs.existsSync(indexFromDist)) { + const health = await resolveControlUiDistIndexHealth({ argv1: process.argv[1] }); + const indexFromDist = health.indexPath; + if (health.exists) { return { ok: true, built: false }; } @@ -207,7 +234,7 @@ export async function ensureControlUiAssetsBuilt( }; } - const indexPath = path.join(repoRoot, "dist", "control-ui", "index.html"); + const indexPath = resolveControlUiDistIndexPathForRoot(repoRoot); if (fs.existsSync(indexPath)) { return { ok: true, built: false }; } diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts new file mode 100644 index 0000000000..8ea5874e7b --- /dev/null +++ b/src/infra/run-node.test.ts @@ -0,0 +1,84 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +async function withTempDir(run: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-node-")); + try { + return await run(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("run-node script", () => { + it.runIf(process.platform !== "win32")( + "preserves control-ui assets by building with tsdown --no-clean", + async () => { + await withTempDir(async (tmp) => { + const runNodeScript = path.join(process.cwd(), "scripts", "run-node.mjs"); + const fakeBinDir = path.join(tmp, ".fake-bin"); + const fakePnpmPath = path.join(fakeBinDir, "pnpm"); + const argsPath = path.join(tmp, ".pnpm-args.txt"); + const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); + + await fs.mkdir(fakeBinDir, { recursive: true }); + await fs.mkdir(path.join(tmp, "src"), { recursive: true }); + await fs.mkdir(path.dirname(indexPath), { recursive: true }); + await fs.writeFile(path.join(tmp, "src", "index.ts"), "export {};\n", "utf-8"); + await fs.writeFile( + path.join(tmp, "package.json"), + JSON.stringify({ name: "openclaw" }), + "utf-8", + ); + await fs.writeFile( + path.join(tmp, "tsconfig.json"), + JSON.stringify({ compilerOptions: {} }), + "utf-8", + ); + await fs.writeFile(indexPath, "sentinel\n", "utf-8"); + + await fs.writeFile( + path.join(tmp, "openclaw.mjs"), + "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('9.9.9-test');\n", + "utf-8", + ); + await fs.chmod(path.join(tmp, "openclaw.mjs"), 0o755); + + const fakePnpm = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +const cwd = process.cwd(); +fs.writeFileSync(path.join(cwd, ".pnpm-args.txt"), args.join(" "), "utf-8"); +if (!args.includes("--no-clean")) { + fs.rmSync(path.join(cwd, "dist", "control-ui"), { recursive: true, force: true }); +} +fs.mkdirSync(path.join(cwd, "dist"), { recursive: true }); +fs.writeFileSync(path.join(cwd, "dist", "entry.js"), "export {}\\n", "utf-8"); +`; + await fs.writeFile(fakePnpmPath, fakePnpm, "utf-8"); + await fs.chmod(fakePnpmPath, 0o755); + + const env = { + ...process.env, + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + OPENCLAW_FORCE_BUILD: "1", + OPENCLAW_RUNNER_LOG: "0", + }; + const result = spawnSync(process.execPath, [runNodeScript, "--version"], { + cwd: tmp, + env, + encoding: "utf-8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("9.9.9-test"); + await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean"); + await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); + }); + }, + ); +}); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 35e22f2bd0..a6c6e28d4e 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -35,6 +35,7 @@ describe("runGatewayUpdate", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-")); + await fs.writeFile(path.join(tempDir, "openclaw.mjs"), "export {};\n", "utf-8"); }); afterEach(async () => { @@ -106,6 +107,9 @@ describe("runGatewayUpdate", () => { JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), "utf-8", ); + const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); + await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); + await fs.writeFile(uiIndexPath, "", "utf-8"); const stableTag = "v1.0.1-1"; const betaTag = "v1.0.0-beta.2"; const { runner, calls } = createRunner({ @@ -120,8 +124,9 @@ describe("runGatewayUpdate", () => { "pnpm install": { stdout: "" }, "pnpm build": { stdout: "" }, "pnpm ui:build": { stdout: "" }, - [`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" }, - "pnpm openclaw doctor --non-interactive": { stdout: "" }, + [`${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`]: { + stdout: "", + }, }); const result = await runGatewayUpdate({ @@ -424,4 +429,177 @@ describe("runGatewayUpdate", () => { expect(result.reason).toBe("not-openclaw-root"); expect(calls.some((call) => call.includes("status --porcelain"))).toBe(false); }); + + it("fails with a clear reason when openclaw.mjs is missing", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), + "utf-8", + ); + await fs.rm(path.join(tempDir, "openclaw.mjs"), { force: true }); + + const stableTag = "v1.0.1-1"; + const { runner } = createRunner({ + [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, + [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, + [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" }, + [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, + [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` }, + [`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + "pnpm ui:build": { stdout: "" }, + }); + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runner(argv), + timeoutMs: 5000, + channel: "stable", + }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("doctor-entry-missing"); + expect(result.steps.at(-1)?.name).toBe("openclaw doctor entry"); + }); + + it("repairs UI assets when doctor run removes control-ui files", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), + "utf-8", + ); + const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); + await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); + await fs.writeFile(uiIndexPath, "", "utf-8"); + + const stableTag = "v1.0.1-1"; + const calls: string[] = []; + let uiBuildCount = 0; + + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { + return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + uiBuildCount += 1; + await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); + await fs.writeFile(uiIndexPath, `${uiBuildCount}`, "utf-8"); + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` + ) { + await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true }); + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + channel: "stable", + }); + + expect(result.status).toBe("ok"); + expect(uiBuildCount).toBe(2); + expect(await pathExists(uiIndexPath)).toBe(true); + expect(calls).toContain( + `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive`, + ); + }); + + it("fails when UI assets are still missing after post-doctor repair", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0", packageManager: "pnpm@8.0.0" }), + "utf-8", + ); + const uiIndexPath = path.join(tempDir, "dist", "control-ui", "index.html"); + await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); + await fs.writeFile(uiIndexPath, "", "utf-8"); + + const stableTag = "v1.0.1-1"; + let uiBuildCount = 0; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} tag --list v* --sort=-v:refname`) { + return { stdout: `${stableTag}\n`, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} checkout --detach ${stableTag}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm install") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm build") { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm ui:build") { + uiBuildCount += 1; + if (uiBuildCount === 1) { + await fs.mkdir(path.dirname(uiIndexPath), { recursive: true }); + await fs.writeFile(uiIndexPath, "built", "utf-8"); + } + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key === `${process.execPath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive` + ) { + await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true }); + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + channel: "stable", + }); + + expect(result.status).toBe("error"); + expect(result.reason).toBe("ui-assets-missing"); + }); }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 20bf9837ee..ac774a1412 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; +import { + resolveControlUiDistIndexHealth, + resolveControlUiDistIndexPathForRoot, +} from "./control-ui-assets.js"; import { trimLogTail } from "./restart-sentinel.js"; import { channelToNpmTag, @@ -746,16 +750,89 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< ); steps.push(uiBuildStep); + const doctorEntry = path.join(gitRoot, "openclaw.mjs"); + const doctorEntryExists = await fs + .stat(doctorEntry) + .then(() => true) + .catch(() => false); + if (!doctorEntryExists) { + steps.push({ + name: "openclaw doctor entry", + command: `verify ${doctorEntry}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 1, + stderrTail: `missing ${doctorEntry}`, + }); + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "doctor-entry-missing", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const doctorArgv = [process.execPath, doctorEntry, "doctor", "--non-interactive"]; const doctorStep = await runStep( - step( - "openclaw doctor", - managerScriptArgs(manager, "openclaw", ["doctor", "--non-interactive"]), - gitRoot, - { OPENCLAW_UPDATE_IN_PROGRESS: "1" }, - ), + step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1" }), ); steps.push(doctorStep); + const uiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!uiIndexHealth.exists) { + const repairArgv = managerScriptArgs(manager, "ui:build"); + const started = Date.now(); + const repairResult = await runCommand(repairArgv, { cwd: gitRoot, timeoutMs }); + const repairStep: UpdateStepResult = { + name: "ui:build (post-doctor repair)", + command: repairArgv.join(" "), + cwd: gitRoot, + durationMs: Date.now() - started, + exitCode: repairResult.code, + stdoutTail: trimLogTail(repairResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(repairResult.stderr, MAX_LOG_CHARS), + }; + steps.push(repairStep); + + if (repairResult.code !== 0) { + return { + status: "error", + mode: "git", + root: gitRoot, + reason: repairStep.name, + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + + const repairedUiIndexHealth = await resolveControlUiDistIndexHealth({ root: gitRoot }); + if (!repairedUiIndexHealth.exists) { + const uiIndexPath = + repairedUiIndexHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(gitRoot); + steps.push({ + name: "ui assets verify", + command: `verify ${uiIndexPath}`, + cwd: gitRoot, + durationMs: 0, + exitCode: 1, + stderrTail: `missing ${uiIndexPath}`, + }); + return { + status: "error", + mode: "git", + root: gitRoot, + reason: "ui-assets-missing", + before: { sha: beforeSha, version: beforeVersion }, + steps, + durationMs: Date.now() - startedAt, + }; + } + } + const failedStep = steps.find((s) => s.exitCode !== 0); const afterShaStep = await runStep( step("git rev-parse HEAD (after)", ["git", "-C", gitRoot, "rev-parse", "HEAD"], gitRoot),