feat(doctor): add UI protocol freshness check

This commit is contained in:
meaningfool
2026-01-12 17:41:03 +01:00
committed by Peter Steinberger
parent 93ae3b8405
commit bfdbaa5ab6
2 changed files with 105 additions and 22 deletions

84
src/commands/doctor-ui.ts Normal file
View File

@@ -0,0 +1,84 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
export async function maybeRepairUiProtocolFreshness(
_runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
if (!root) return;
const schemaPath = path.join(root, "src/gateway/protocol/schema.ts");
const uiIndexPath = path.join(root, "dist/control-ui/index.html");
try {
const [schemaStats, uiStats] = await Promise.all([
fs.stat(schemaPath),
fs.stat(uiIndexPath),
]);
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",
"src/gateway/protocol/schema.ts",
],
{ timeoutMs: 5000 },
).catch(() => null);
if (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.confirmAggressive({
message:
"Rebuild UI now? (Detected protocol mismatch requiring update)",
initialValue: true,
});
if (shouldRepair) {
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");
await runCommandWithTimeout(
[process.execPath, uiScriptPath, "build"],
{
cwd: root,
timeoutMs: 120_000,
env: { ...process.env, FORCE_COLOR: "1" },
},
);
note("UI rebuild complete.", "UI");
}
}
}
} catch (_err) {
// If files don't exist, we can't check.
// If git fails, we silently skip.
// runtime.debug(`UI freshness check failed: ${String(err)}`);
}
}

View File

@@ -66,6 +66,7 @@ import {
maybeMigrateLegacyConfigFile,
normalizeLegacyConfigValues,
} from "./doctor-legacy-config.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
import {
maybeRepairSandboxImages,
@@ -271,9 +272,9 @@ export async function doctorCommand(
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
message: "Migrate legacy config entries now?",
initialValue: true,
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(
@@ -326,9 +327,9 @@ export async function doctorCommand(
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
@@ -354,9 +355,9 @@ export async function doctorCommand(
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
@@ -478,13 +479,11 @@ export async function doctorCommand(
note(
[
`Eligible: ${skillsReport.skills.filter((s) => s.eligible).length}`,
`Missing requirements: ${
skillsReport.skills.filter(
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist,
).length
`Missing requirements: ${skillsReport.skills.filter(
(s) => !s.eligible && !s.disabled && !s.blockedByAllowlist,
).length
}`,
`Blocked by allowlist: ${
skillsReport.skills.filter((s) => s.blockedByAllowlist).length
`Blocked by allowlist: ${skillsReport.skills.filter((s) => s.blockedByAllowlist).length
}`,
].join("\n"),
"Skills status",
@@ -494,10 +493,10 @@ export async function doctorCommand(
config: cfg,
workspaceDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
info: () => { },
warn: () => { },
error: () => { },
debug: () => { },
},
});
if (pluginRegistry.plugins.length > 0) {
@@ -513,9 +512,9 @@ export async function doctorCommand(
`Errors: ${errored.length}`,
errored.length > 0
? `- ${errored
.slice(0, 10)
.map((p) => p.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
.slice(0, 10)
.map((p) => p.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`
: null,
].filter((line): line is string => Boolean(line));