mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 15:34:06 +00:00
feat(doctor): expose UI freshness health findings
Expose UI freshness doctor findings through the structured health contribution path so lint JSON and dry-run repair output include stale UI asset guidance. Keep legacy positional repair filtering stable while excluding health checks that already own their structured repair output. Maintainer fixups also avoid stale UI warnings when git history cannot prove changed sources, and make the foreground timeout regression test deterministic. Verification: - Local: `git diff --check origin/main...HEAD` - Local: `node_modules/.bin/oxfmt --check --threads=1 src/agents/bash-tools.exec-foreground-failures.test.ts src/commands/doctor-ui.test.ts src/commands/doctor-ui.ts src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-contributions.ts src/flows/doctor-health-conversion-plan.ts` - Local: `node_modules/.bin/oxlint --tsconfig config/tsconfig/oxlint.core.json src/agents/bash-tools.exec-foreground-failures.test.ts src/commands/doctor-ui.test.ts src/commands/doctor-ui.ts src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.test.ts src/flows/doctor-health-contributions.ts src/flows/doctor-health-conversion-plan.ts` - Local: `node scripts/run-vitest.mjs src/commands/doctor-ui.test.ts src/flows/doctor-health-contributions.test.ts src/commands/doctor-lint.test.ts src/agents/bash-tools.exec-foreground-failures.test.ts --reporter=dot --pool=forks --testTimeout=30000 --hookTimeout=30000` - Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p tsconfig.core.json --noEmit --incremental false --pretty false` - Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --noEmit --incremental false --pretty false` - Local: `GOMAXPROCS=4 node scripts/run-tsgo.mjs -p tsconfig.extensions.json --noEmit --incremental false --pretty false` - Autoreview: `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` - GitHub Actions: CI `26708647282`, Real behavior proof `26708646476`, CodeQL `26708647258`, CodeQL Critical Quality `26708647230`, OpenGrep PR Diff `26708647214`, Workflow Sanity `26708647232`, Dependency Guard `26708646489`, ClawSweeper Dispatch `26708646475`, Labeler `26708646480` Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
This commit is contained in:
@@ -1,14 +1,26 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SpawnInput } from "../process/supervisor/index.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import { resolveShellFromPath } from "./shell-utils.js";
|
||||
|
||||
const supervisorMock = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => supervisorMock,
|
||||
}));
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const defaultShell = isWin
|
||||
? undefined
|
||||
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||
const longDelayCmd = isWin ? "Start-Sleep -Seconds 5" : "sleep 5";
|
||||
|
||||
describe("exec foreground failures", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
@@ -19,6 +31,11 @@ describe("exec foreground failures", () => {
|
||||
if (!isWin && defaultShell) {
|
||||
process.env.SHELL = defaultShell;
|
||||
}
|
||||
supervisorMock.spawn.mockReset();
|
||||
supervisorMock.cancel.mockReset();
|
||||
supervisorMock.cancelScope.mockReset();
|
||||
supervisorMock.reconcileOrphans.mockReset();
|
||||
supervisorMock.getRecord.mockReset();
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
@@ -35,11 +52,35 @@ describe("exec foreground failures", () => {
|
||||
backgroundMs: 10,
|
||||
allowBackground: false,
|
||||
});
|
||||
supervisorMock.spawn.mockImplementationOnce(async (input: SpawnInput) => ({
|
||||
runId: input.runId ?? "call-timeout",
|
||||
pid: 1234,
|
||||
startedAtMs: Date.now(),
|
||||
stdin: {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
},
|
||||
wait: vi.fn(async () => ({
|
||||
reason: "overall-timeout" as const,
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
durationMs: input.timeoutMs ?? 50,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
noOutputTimedOut: false,
|
||||
})),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
|
||||
const result = await tool.execute("call-timeout", {
|
||||
command: longDelayCmd,
|
||||
command: "echo never-runs",
|
||||
host: "gateway",
|
||||
});
|
||||
|
||||
expect(supervisorMock.spawn).toHaveBeenCalledOnce();
|
||||
expect((supervisorMock.spawn.mock.calls[0]?.[0] as SpawnInput | undefined)?.timeoutMs).toBe(50);
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
expect((result.content[0] as { text?: string }).text).toMatch(/timed out/i);
|
||||
expect((result.content[0] as { text?: string }).text).toMatch(/re-run with a higher timeout/i);
|
||||
|
||||
123
src/commands/doctor-ui.test.ts
Normal file
123
src/commands/doctor-ui.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectUiProtocolFreshnessIssues,
|
||||
uiProtocolFreshnessIssueToHealthFinding,
|
||||
uiProtocolFreshnessIssueToRepairEffects,
|
||||
type UiProtocolFreshnessIssue,
|
||||
} from "./doctor-ui.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function issue(overrides: Partial<UiProtocolFreshnessIssue> = {}): UiProtocolFreshnessIssue {
|
||||
return {
|
||||
kind: "missing-assets",
|
||||
root: "/repo/openclaw",
|
||||
uiIndexPath: "/repo/openclaw/dist/control-ui/index.html",
|
||||
canBuild: true,
|
||||
...overrides,
|
||||
} as UiProtocolFreshnessIssue;
|
||||
}
|
||||
|
||||
async function createOpenClawRoot(): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-ui-"));
|
||||
tempRoots.push(root);
|
||||
await fs.writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||
await fs.mkdir(path.join(root, "packages/gateway-protocol/src"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "packages/gateway-protocol/src/schema.ts"), "export {};\n");
|
||||
return root;
|
||||
}
|
||||
|
||||
async function touch(filePath: string, date: Date): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, "");
|
||||
await fs.utimes(filePath, date, date);
|
||||
}
|
||||
|
||||
describe("UI protocol freshness health mapping", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps missing UI assets to a structured finding and dry-run effect", () => {
|
||||
const current = issue();
|
||||
|
||||
expect(uiProtocolFreshnessIssueToHealthFinding(current)).toEqual(
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/ui-protocol-freshness",
|
||||
severity: "warning",
|
||||
path: "/repo/openclaw/dist/control-ui/index.html",
|
||||
fixHint: expect.stringContaining("openclaw doctor --fix"),
|
||||
}),
|
||||
);
|
||||
expect(uiProtocolFreshnessIssueToRepairEffects(current)).toEqual([
|
||||
{
|
||||
kind: "process",
|
||||
action: "would-build-control-ui",
|
||||
target: "/repo/openclaw",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps stale UI assets to rebuild effects without file diffs", () => {
|
||||
const current = issue({
|
||||
kind: "stale-assets",
|
||||
changesSinceBuild: ["abc123 schema change"],
|
||||
});
|
||||
const finding = uiProtocolFreshnessIssueToHealthFinding(current);
|
||||
|
||||
expect(finding.message).toContain("abc123 schema change");
|
||||
expect(finding.fixHint).toContain("openclaw doctor --fix --force");
|
||||
expect(uiProtocolFreshnessIssueToRepairEffects(current)).toEqual([
|
||||
{
|
||||
kind: "process",
|
||||
action: "would-rebuild-control-ui",
|
||||
target: "/repo/openclaw",
|
||||
dryRunSafe: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report dry-run effects when UI sources are unavailable", () => {
|
||||
expect(uiProtocolFreshnessIssueToRepairEffects(issue({ canBuild: false }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not report stale assets when git finds no schema changes", async () => {
|
||||
const root = await createOpenClawRoot();
|
||||
const schemaPath = path.join(root, "packages/gateway-protocol/src/schema.ts");
|
||||
const uiIndexPath = path.join(root, "dist/control-ui/index.html");
|
||||
await touch(uiIndexPath, new Date("2026-01-01T00:00:00.000Z"));
|
||||
await touch(schemaPath, new Date("2026-01-02T00:00:00.000Z"));
|
||||
|
||||
await expect(
|
||||
detectUiProtocolFreshnessIssues({
|
||||
root,
|
||||
async collectChangesSinceBuild() {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not report stale assets when git history is unavailable", async () => {
|
||||
const root = await createOpenClawRoot();
|
||||
const schemaPath = path.join(root, "packages/gateway-protocol/src/schema.ts");
|
||||
const uiIndexPath = path.join(root, "dist/control-ui/index.html");
|
||||
await touch(uiIndexPath, new Date("2026-01-01T00:00:00.000Z"));
|
||||
await touch(schemaPath, new Date("2026-01-02T00:00:00.000Z"));
|
||||
|
||||
await expect(
|
||||
detectUiProtocolFreshnessIssues({
|
||||
root,
|
||||
async collectChangesSinceBuild() {
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import {
|
||||
resolveControlUiDistIndexHealth,
|
||||
resolveControlUiDistIndexPathForRoot,
|
||||
@@ -10,55 +11,177 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
export async function maybeRepairUiProtocolFreshness(
|
||||
_runtime: RuntimeEnv,
|
||||
prompter: DoctorPrompter,
|
||||
) {
|
||||
const root = await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
export type UiProtocolFreshnessIssue =
|
||||
| {
|
||||
readonly kind: "missing-assets";
|
||||
readonly root: string;
|
||||
readonly uiIndexPath: string;
|
||||
readonly canBuild: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "stale-assets";
|
||||
readonly root: string;
|
||||
readonly uiIndexPath: string;
|
||||
readonly changesSinceBuild: readonly string[];
|
||||
readonly canBuild: boolean;
|
||||
};
|
||||
|
||||
export async function detectUiProtocolFreshnessIssues(
|
||||
opts: {
|
||||
readonly root?: string;
|
||||
readonly argv1?: string;
|
||||
readonly cwd?: string;
|
||||
readonly collectChangesSinceBuild?: (
|
||||
root: string,
|
||||
uiMtime: Date,
|
||||
) => Promise<readonly string[] | null>;
|
||||
} = {},
|
||||
): Promise<readonly UiProtocolFreshnessIssue[]> {
|
||||
const root =
|
||||
opts.root ??
|
||||
(await resolveOpenClawPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
argv1: opts.argv1 ?? process.argv[1],
|
||||
cwd: opts.cwd ?? process.cwd(),
|
||||
}));
|
||||
if (!root) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaPath = path.join(root, "packages/gateway-protocol/src/schema.ts");
|
||||
const uiHealth = await resolveControlUiDistIndexHealth({
|
||||
root,
|
||||
argv1: process.argv[1],
|
||||
argv1: opts.argv1 ?? process.argv[1],
|
||||
});
|
||||
const uiIndexPath = uiHealth.indexPath ?? resolveControlUiDistIndexPathForRoot(root);
|
||||
const uiSourcesPath = path.join(root, "ui/package.json");
|
||||
|
||||
try {
|
||||
const [schemaStats, uiStats] = await Promise.all([
|
||||
const [schemaStats, uiStats, uiSourcesStats] = await Promise.all([
|
||||
fs.stat(schemaPath).catch(() => null),
|
||||
fs.stat(uiIndexPath).catch(() => null),
|
||||
fs.stat(uiSourcesPath).catch(() => null),
|
||||
]);
|
||||
if (!schemaStats) {
|
||||
return [];
|
||||
}
|
||||
const canBuild = uiSourcesStats !== null;
|
||||
if (!uiStats) {
|
||||
return [{ kind: "missing-assets", root, uiIndexPath, canBuild }];
|
||||
}
|
||||
if (schemaStats.mtime <= uiStats.mtime) {
|
||||
return [];
|
||||
}
|
||||
const changesSinceBuild = await (
|
||||
opts.collectChangesSinceBuild ?? collectProtocolSchemaChangesSince
|
||||
)(root, uiStats.mtime);
|
||||
if (changesSinceBuild === null || changesSinceBuild.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "stale-assets",
|
||||
root,
|
||||
uiIndexPath,
|
||||
changesSinceBuild,
|
||||
canBuild,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (schemaStats && !uiStats) {
|
||||
note(["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n"), "UI");
|
||||
async function collectProtocolSchemaChangesSince(
|
||||
root: string,
|
||||
uiMtime: Date,
|
||||
): Promise<readonly string[] | null> {
|
||||
const gitLog = await runCommandWithTimeout(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"log",
|
||||
`--since=${uiMtime.toISOString()}`,
|
||||
"--format=%h %s",
|
||||
"packages/gateway-protocol/src/schema.ts",
|
||||
],
|
||||
{ timeoutMs: 5000 },
|
||||
).catch(() => null);
|
||||
if (!gitLog || gitLog.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (!gitLog.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
return gitLog.stdout.trim().split("\n");
|
||||
}
|
||||
|
||||
// In slim/docker environments we may not have the UI source tree. Trying
|
||||
// to build would fail (and spam logs), so skip the interactive repair.
|
||||
const uiSourcesPath = path.join(root, "ui/package.json");
|
||||
const uiSourcesExist = await fs.stat(uiSourcesPath).catch(() => null);
|
||||
if (!uiSourcesExist) {
|
||||
export function uiProtocolFreshnessIssueToHealthFinding(
|
||||
issue: UiProtocolFreshnessIssue,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: "core/doctor/ui-protocol-freshness",
|
||||
severity: "warning",
|
||||
message: formatUiProtocolFreshnessIssue(issue),
|
||||
path: issue.uiIndexPath,
|
||||
fixHint: issue.canBuild
|
||||
? issue.kind === "missing-assets"
|
||||
? "Run `openclaw doctor --fix` to build Control UI assets."
|
||||
: "Run `openclaw doctor --fix --force` to rebuild Control UI assets, or run `pnpm ui:build`."
|
||||
: "Install from a source checkout with ui/ sources, then run `pnpm ui:build`.",
|
||||
};
|
||||
}
|
||||
|
||||
export function uiProtocolFreshnessIssueToRepairEffects(
|
||||
issue: UiProtocolFreshnessIssue,
|
||||
): readonly HealthRepairEffect[] {
|
||||
if (!issue.canBuild) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "process",
|
||||
action:
|
||||
issue.kind === "missing-assets" ? "would-build-control-ui" : "would-rebuild-control-ui",
|
||||
target: issue.root,
|
||||
dryRunSafe: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatUiProtocolFreshnessIssue(issue: UiProtocolFreshnessIssue): string {
|
||||
if (issue.kind === "missing-assets") {
|
||||
return ["- Control UI assets are missing.", "- Run: pnpm ui:build"].join("\n");
|
||||
}
|
||||
if (issue.changesSinceBuild.length === 0) {
|
||||
return "UI assets are older than the protocol schema.";
|
||||
}
|
||||
return `UI assets are older than the protocol schema.\nFunctional changes since last build:\n${issue.changesSinceBuild
|
||||
.map((line) => `- ${line}`)
|
||||
.join("\n")}`;
|
||||
}
|
||||
|
||||
export async function maybeRepairUiProtocolFreshness(
|
||||
_runtime: RuntimeEnv,
|
||||
prompter: DoctorPrompter,
|
||||
) {
|
||||
for (const issue of await detectUiProtocolFreshnessIssues()) {
|
||||
if (issue.kind === "missing-assets") {
|
||||
note(formatUiProtocolFreshnessIssue(issue), "UI");
|
||||
if (!issue.canBuild) {
|
||||
note("Skipping UI build: ui/ sources not present.", "UI");
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const shouldRepair = await prompter.confirmAutoFix({
|
||||
message: "Build Control UI assets now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (shouldRepair) {
|
||||
note("Building Control UI assets... (this may take a moment)", "UI");
|
||||
const uiScriptPath = path.join(root, "scripts/ui.js");
|
||||
const uiScriptPath = path.join(issue.root, "scripts/ui.js");
|
||||
const buildResult = await runCommandWithTimeout([process.execPath, uiScriptPath, "build"], {
|
||||
cwd: root,
|
||||
cwd: issue.root,
|
||||
timeoutMs: 120_000,
|
||||
env: { ...process.env, FORCE_COLOR: "1" },
|
||||
});
|
||||
@@ -74,81 +197,37 @@ export async function maybeRepairUiProtocolFreshness(
|
||||
note(details, "UI");
|
||||
}
|
||||
}
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!schemaStats || !uiStats) {
|
||||
return;
|
||||
note(formatUiProtocolFreshnessIssue(issue), "UI Freshness");
|
||||
if (!issue.canBuild) {
|
||||
note("Skipping UI rebuild: ui/ sources not present.", "UI");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schemaStats.mtime > uiStats.mtime) {
|
||||
const uiMtimeIso = uiStats.mtime.toISOString();
|
||||
// Find changes since the UI build
|
||||
const gitLog = await runCommandWithTimeout(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"log",
|
||||
`--since=${uiMtimeIso}`,
|
||||
"--format=%h %s",
|
||||
"packages/gateway-protocol/src/schema.ts",
|
||||
],
|
||||
{ timeoutMs: 5000 },
|
||||
).catch(() => null);
|
||||
|
||||
if (gitLog && gitLog.code === 0 && gitLog.stdout.trim()) {
|
||||
note(
|
||||
`UI assets are older than the protocol schema.\nFunctional changes since last build:\n${gitLog.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => `- ${l}`)
|
||||
.join("\n")}`,
|
||||
"UI Freshness",
|
||||
);
|
||||
|
||||
const shouldRepair = await prompter.confirmAggressiveAutoFix({
|
||||
message: "Rebuild UI now? (Detected protocol mismatch requiring update)",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (shouldRepair) {
|
||||
const uiSourcesPath = path.join(root, "ui/package.json");
|
||||
const uiSourcesExist = await fs.stat(uiSourcesPath).catch(() => null);
|
||||
if (!uiSourcesExist) {
|
||||
note("Skipping UI rebuild: ui/ sources not present.", "UI");
|
||||
return;
|
||||
}
|
||||
|
||||
note("Rebuilding stale UI assets... (this may take a moment)", "UI");
|
||||
// Use scripts/ui.js to build, assuming node is available as we are running in it.
|
||||
// We use the same node executable to run the script.
|
||||
const uiScriptPath = path.join(root, "scripts/ui.js");
|
||||
const buildResult = await runCommandWithTimeout(
|
||||
[process.execPath, uiScriptPath, "build"],
|
||||
{
|
||||
cwd: root,
|
||||
timeoutMs: 120_000,
|
||||
env: { ...process.env, FORCE_COLOR: "1" },
|
||||
},
|
||||
);
|
||||
if (buildResult.code === 0) {
|
||||
note("UI rebuild complete.", "UI");
|
||||
} else {
|
||||
const details = [
|
||||
`UI rebuild failed (exit ${buildResult.code ?? "unknown"}).`,
|
||||
buildResult.stderr.trim() ? buildResult.stderr.trim() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
note(details, "UI");
|
||||
}
|
||||
}
|
||||
const shouldRepair = await prompter.confirmAggressiveAutoFix({
|
||||
message: "Rebuild UI now? (Detected protocol mismatch requiring update)",
|
||||
initialValue: true,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
note("Rebuilding stale UI assets... (this may take a moment)", "UI");
|
||||
const uiScriptPath = path.join(issue.root, "scripts/ui.js");
|
||||
const buildResult = await runCommandWithTimeout([process.execPath, uiScriptPath, "build"], {
|
||||
cwd: issue.root,
|
||||
timeoutMs: 120_000,
|
||||
env: { ...process.env, FORCE_COLOR: "1" },
|
||||
});
|
||||
if (buildResult.code === 0) {
|
||||
note("UI rebuild complete.", "UI");
|
||||
} else {
|
||||
const details = [
|
||||
`UI rebuild failed (exit ${buildResult.code ?? "unknown"}).`,
|
||||
buildResult.stderr.trim() ? buildResult.stderr.trim() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
note(details, "UI");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If files don't exist, we can't check.
|
||||
// If git fails, we silently skip.
|
||||
// runtime.debug(`UI freshness check failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
shellCompletionStatusToRepairEffects,
|
||||
} from "../commands/doctor-completion.js";
|
||||
import { disableUnavailableSkillsInConfig } from "../commands/doctor-skills-core.js";
|
||||
import {
|
||||
detectUiProtocolFreshnessIssues,
|
||||
uiProtocolFreshnessIssueToHealthFinding,
|
||||
uiProtocolFreshnessIssueToRepairEffects,
|
||||
} from "../commands/doctor-ui.js";
|
||||
import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js";
|
||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||
@@ -832,6 +837,30 @@ const shellCompletionCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
const uiProtocolFreshnessCheck: HealthCheck = {
|
||||
id: "core/doctor/ui-protocol-freshness",
|
||||
kind: "core",
|
||||
description: "Control UI assets are present and current with the Gateway protocol schema.",
|
||||
source: "doctor",
|
||||
async detect() {
|
||||
return (await detectUiProtocolFreshnessIssues()).map(uiProtocolFreshnessIssueToHealthFinding);
|
||||
},
|
||||
async repair(ctx) {
|
||||
const effects = (await detectUiProtocolFreshnessIssues()).flatMap(
|
||||
uiProtocolFreshnessIssueToRepairEffects,
|
||||
);
|
||||
if (ctx.dryRun === true) {
|
||||
return { status: "repaired", changes: [], effects };
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "legacy doctor UI freshness repair owns real mutations",
|
||||
changes: [],
|
||||
effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck {
|
||||
return {
|
||||
id: "core/doctor/workspace-suggestions",
|
||||
@@ -860,6 +889,7 @@ function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly Heal
|
||||
legacyStateCheck,
|
||||
legacyWhatsAppCrontabCheck,
|
||||
shellCompletionCheck,
|
||||
uiProtocolFreshnessCheck,
|
||||
gatewayPlatformNotesCheck,
|
||||
createSecurityCheck(deps),
|
||||
browserCheck,
|
||||
|
||||
@@ -147,6 +147,7 @@ describe("doctor health contributions", () => {
|
||||
mocks.listHealthChecks.mockReset();
|
||||
mocks.listHealthChecks.mockReturnValue([
|
||||
{ id: "core/doctor/shell-completion" },
|
||||
{ id: "core/doctor/ui-protocol-freshness" },
|
||||
{ id: "core/doctor/unrelated" },
|
||||
]);
|
||||
mocks.getHealthCheck.mockReset();
|
||||
@@ -319,7 +320,7 @@ describe("doctor health contributions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy positional shell completion out of the broad structured repair pass", async () => {
|
||||
it("keeps legacy positional repairs out of the broad structured repair pass", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:structured-health-repairs");
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
|
||||
@@ -52,7 +52,7 @@ type DoctorHealthContribution = FlowContribution & {
|
||||
run: (ctx: DoctorHealthFlowContext) => Promise<void>;
|
||||
};
|
||||
|
||||
const LEGACY_POSITIONAL_REPAIR_CHECK_IDS = new Set(["core/doctor/shell-completion"]);
|
||||
const PRE_HEALTH_POSITIONAL_HEALTH_CHECK_IDS = new Set(["core/doctor/ui-protocol-freshness"]);
|
||||
|
||||
const loadAgentDefaultsModule = async () => await import("../agents/defaults.js");
|
||||
const loadAgentScopeModule = async () => await import("../agents/agent-scope.js");
|
||||
@@ -119,6 +119,19 @@ function createDoctorHealthContribution(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePositionalHealthCheckIds(): ReadonlySet<string> {
|
||||
const ids = new Set(PRE_HEALTH_POSITIONAL_HEALTH_CHECK_IDS);
|
||||
for (const contribution of resolveDoctorHealthContributions()) {
|
||||
if (contribution.id === "doctor:structured-health-repairs") {
|
||||
continue;
|
||||
}
|
||||
for (const checkId of contribution.healthCheckIds) {
|
||||
ids.add(checkId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { formatCliCommand } = await loadCommandFormatModule();
|
||||
const { hasAmbiguousGatewayAuthModeConfig } = await import("../gateway/auth-mode-policy.js");
|
||||
@@ -314,9 +327,8 @@ async function runStructuredHealthRepairs(ctx: DoctorHealthFlowContext): Promise
|
||||
registerCoreHealthChecks();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
|
||||
registerBundledHealthChecks({ cfg: ctx.cfg, cwd: workspaceDir });
|
||||
const checks = listHealthChecks().filter(
|
||||
(check) => !LEGACY_POSITIONAL_REPAIR_CHECK_IDS.has(check.id),
|
||||
);
|
||||
const positionalHealthCheckIds = resolvePositionalHealthCheckIds();
|
||||
const checks = listHealthChecks().filter((check) => !positionalHealthCheckIds.has(check.id));
|
||||
const result = await runDoctorHealthRepairs(
|
||||
{
|
||||
mode: "fix",
|
||||
@@ -1140,6 +1152,7 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:shell-completion",
|
||||
label: "Shell completion",
|
||||
healthCheckIds: ["core/doctor/shell-completion"],
|
||||
run: runShellCompletionHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
|
||||
@@ -54,8 +54,8 @@ export const doctorHealthConversionRules = [
|
||||
{
|
||||
contributionId: "doctor:structured-health-repairs",
|
||||
conversion: "terminal-side-effect",
|
||||
target: ["doctor-health-repair-runner"],
|
||||
rule: "Delete this bridge after converted checks are registered directly; repair orchestration belongs outside the contribution list.",
|
||||
target: ["doctor-health-repair-runner", "core/doctor/ui-protocol-freshness"],
|
||||
rule: "Delete this bridge after converted checks are registered directly; repair orchestration belongs outside the contribution list. UI freshness is registered for lint/dry-run effects while legacy doctor still owns real repair.",
|
||||
},
|
||||
{
|
||||
contributionId: "doctor:legacy-state",
|
||||
|
||||
Reference in New Issue
Block a user