diff --git a/src/agents/bash-tools.exec-foreground-failures.test.ts b/src/agents/bash-tools.exec-foreground-failures.test.ts index 5593fbfc7fd..23e6c6fd417 100644 --- a/src/agents/bash-tools.exec-foreground-failures.test.ts +++ b/src/agents/bash-tools.exec-foreground-failures.test.ts @@ -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; @@ -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); diff --git a/src/commands/doctor-ui.test.ts b/src/commands/doctor-ui.test.ts new file mode 100644 index 00000000000..8433cf4a178 --- /dev/null +++ b/src/commands/doctor-ui.test.ts @@ -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 { + return { + kind: "missing-assets", + root: "/repo/openclaw", + uiIndexPath: "/repo/openclaw/dist/control-ui/index.html", + canBuild: true, + ...overrides, + } as UiProtocolFreshnessIssue; +} + +async function createOpenClawRoot(): Promise { + 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 { + 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([]); + }); +}); diff --git a/src/commands/doctor-ui.ts b/src/commands/doctor-ui.ts index 544448959ab..69d2a6ec41c 100644 --- a/src/commands/doctor-ui.ts +++ b/src/commands/doctor-ui.ts @@ -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; + } = {}, +): Promise { + 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 { + 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)}`); } } diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index f83a29b3679..a78ff5bfaa6 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -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, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index dcd6dcc77cc..07eceb61866 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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: {}, diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index bf0abc4f75b..40c14e5ccef 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -52,7 +52,7 @@ type DoctorHealthContribution = FlowContribution & { run: (ctx: DoctorHealthFlowContext) => Promise; }; -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 { + 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 { 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({ diff --git a/src/flows/doctor-health-conversion-plan.ts b/src/flows/doctor-health-conversion-plan.ts index 8c45f1d943f..c10437113f7 100644 --- a/src/flows/doctor-health-conversion-plan.ts +++ b/src/flows/doctor-health-conversion-plan.ts @@ -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",