From 258524973798a98e9fdb7b50e2a8bf205afeefe4 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Thu, 21 May 2026 10:47:43 +0800 Subject: [PATCH] perf: isolate doctor core check tests (#84493) Merged via squash. Prepared head SHA: 6229656ba16e84b15b851e9946ec4c3090ea4c16 Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 7 +- src/commands/doctor-skills-core.ts | 35 +++ src/commands/doctor-skills.ts | 42 +--- src/flows/doctor-core-checks.e2e.test.ts | 114 ++++++++++ src/flows/doctor-core-checks.runtime.ts | 14 ++ src/flows/doctor-core-checks.test.ts | 190 +++++++++++----- src/flows/doctor-core-checks.ts | 263 ++++++++++++----------- 7 files changed, 454 insertions(+), 211 deletions(-) create mode 100644 src/commands/doctor-skills-core.ts create mode 100644 src/flows/doctor-core-checks.e2e.test.ts create mode 100644 src/flows/doctor-core-checks.runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a242e8ba9..dbb823780d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn. + ## 2026.5.20 ### Changes @@ -68,7 +74,6 @@ Docs: https://docs.openclaw.ai - CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev. - Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79. - CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant. - - Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev. - Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev. - Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant. diff --git a/src/commands/doctor-skills-core.ts b/src/commands/doctor-skills-core.ts new file mode 100644 index 00000000000..439ba305fef --- /dev/null +++ b/src/commands/doctor-skills-core.ts @@ -0,0 +1,35 @@ +import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] { + return report.skills.filter( + (skill) => + !skill.eligible && + !skill.disabled && + !skill.blockedByAllowlist && + !skill.blockedByAgentFilter, + ); +} + +export function disableUnavailableSkillsInConfig( + config: OpenClawConfig, + skills: readonly SkillStatusEntry[], +): OpenClawConfig { + if (skills.length === 0) { + return config; + } + const entries = { ...config.skills?.entries }; + for (const skill of skills) { + entries[skill.skillKey] = { + ...entries[skill.skillKey], + enabled: false, + }; + } + return { + ...config, + skills: { + ...config.skills, + entries, + }, + }; +} diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts index 5fe19dc1411..6fcaa1c7f53 100644 --- a/src/commands/doctor-skills.ts +++ b/src/commands/doctor-skills.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; +import type { SkillStatusEntry } from "../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { detectGhConfigDirMismatch, @@ -12,16 +12,15 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { note } from "../terminal/note.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +import { + collectUnavailableAgentSkills, + disableUnavailableSkillsInConfig, +} from "./doctor-skills-core.js"; -export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] { - return report.skills.filter( - (skill) => - !skill.eligible && - !skill.disabled && - !skill.blockedByAllowlist && - !skill.blockedByAgentFilter, - ); -} +export { + collectUnavailableAgentSkills, + disableUnavailableSkillsInConfig, +} from "./doctor-skills-core.js"; function formatMissingSummary(skill: SkillStatusEntry): string { const missing: string[] = []; @@ -100,29 +99,6 @@ export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): s return lines; } -export function disableUnavailableSkillsInConfig( - config: OpenClawConfig, - skills: readonly SkillStatusEntry[], -): OpenClawConfig { - if (skills.length === 0) { - return config; - } - const entries = { ...config.skills?.entries }; - for (const skill of skills) { - entries[skill.skillKey] = { - ...entries[skill.skillKey], - enabled: false, - }; - } - return { - ...config, - skills: { - ...config.skills, - entries, - }, - }; -} - export async function maybeRepairSkillReadiness(params: { cfg: OpenClawConfig; prompter: DoctorPrompter; diff --git a/src/flows/doctor-core-checks.e2e.test.ts b/src/flows/doctor-core-checks.e2e.test.ts new file mode 100644 index 00000000000..b028ab889dd --- /dev/null +++ b/src/flows/doctor-core-checks.e2e.test.ts @@ -0,0 +1,114 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; +import type { HealthCheck } from "./health-checks.js"; + +const runtime = { log() {}, error() {}, exit() {} }; + +function getCheck(id: string): HealthCheck { + const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === id); + if (!check) { + throw new Error(`Missing health check ${id}`); + } + return check; +} + +describe("doctor core skills readiness smoke", () => { + let tmp: string | undefined; + + afterEach(async () => { + if (tmp !== undefined) { + await fs.rm(tmp, { recursive: true, force: true }); + tmp = undefined; + } + }); + + it("detects and repairs a real unavailable workspace skill", async () => { + tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-")); + const skillDir = join(tmp, "skills", "missing-tool"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + join(skillDir, "SKILL.md"), + `--- +name: missing-tool +description: Missing tool +metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}' +--- + +# Missing tool +`, + "utf-8", + ); + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmp, + skills: ["missing-tool"], + }, + }, + }; + const check = getCheck("core/doctor/skills-readiness"); + + const findings = await check.detect({ + mode: "lint", + runtime, + cfg, + cwd: tmp, + }); + expect(findings).toContainEqual( + expect.objectContaining({ + checkId: "core/doctor/skills-readiness", + severity: "warning", + path: "skills.entries.missing-tool.enabled", + }), + ); + await expect( + check.detect( + { + mode: "fix", + runtime, + cfg, + cwd: tmp, + }, + { paths: ["skills.entries.other-tool.enabled"] }, + ), + ).resolves.toEqual([]); + await expect( + check.detect( + { + mode: "fix", + runtime, + cfg, + cwd: tmp, + }, + { paths: ["skills.entries.missing-tool.enabled"] }, + ), + ).resolves.toContainEqual( + expect.objectContaining({ + path: "skills.entries.missing-tool.enabled", + }), + ); + + const repaired = await check.repair?.( + { + mode: "fix", + runtime, + cfg, + cwd: tmp, + }, + findings, + ); + expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false }); + expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool."); + expect(repaired?.effects).toContainEqual( + expect.objectContaining({ + kind: "config", + action: "disable-skill", + target: "skills.entries.missing-tool.enabled", + }), + ); + }); +}); diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts new file mode 100644 index 00000000000..ce824932855 --- /dev/null +++ b/src/flows/doctor-core-checks.runtime.ts @@ -0,0 +1,14 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js"; +import { collectUnavailableAgentSkills } from "../commands/doctor-skills-core.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] { + const agentId = resolveDefaultAgentId(cfg); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const report = buildWorkspaceSkillStatus(workspaceDir, { + config: cfg, + agentId, + }); + return collectUnavailableAgentSkills(report); +} diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 8ec2047ecc4..7effe72b2d3 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -1,10 +1,10 @@ -import { promises as fs } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { SkillStatusEntry } from "../agents/skills-status.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { CORE_HEALTH_CHECKS, + createCoreHealthChecks, + type CoreHealthCheckDeps, registerCoreHealthChecks, resetCoreHealthChecksForTest, } from "./doctor-core-checks.js"; @@ -14,22 +14,76 @@ import { listHealthChecks, registerHealthCheck, } from "./health-check-registry.js"; +import type { HealthCheck } from "./health-checks.js"; + +const runtime = { log() {}, error() {}, exit() {} }; + +function createSkill(overrides: Partial = {}): SkillStatusEntry { + return { + name: "missing-tool", + description: "Missing tool", + source: "workspace", + bundled: false, + filePath: "/tmp/openclaw-test-workspace/skills/missing-tool/SKILL.md", + baseDir: "/tmp/openclaw-test-workspace/skills/missing-tool", + skillKey: "missing-tool", + always: false, + disabled: false, + blockedByAllowlist: false, + blockedByAgentFilter: false, + eligible: false, + modelVisible: false, + userInvocable: true, + commandVisible: false, + requirements: { + bins: ["openclaw-test-missing-skill-bin"], + anyBins: [], + env: [], + config: [], + os: [], + }, + missing: { + bins: ["openclaw-test-missing-skill-bin"], + anyBins: [], + env: [], + config: [], + os: [], + }, + configChecks: [], + install: [], + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}): CoreHealthCheckDeps { + return { + async detectUnavailableSkills(): Promise { + return []; + }, + async collectSecurityWarnings(): Promise { + return []; + }, + async collectWorkspaceSuggestionNotes(): Promise { + return []; + }, + ...overrides, + }; +} + +function getCheck(checks: readonly HealthCheck[], id: string): HealthCheck { + const check = checks.find((entry) => entry.id === id); + if (!check) { + throw new Error(`Missing health check ${id}`); + } + return check; +} describe("registerCoreHealthChecks", () => { - let tmp: string | undefined; - beforeEach(() => { clearHealthChecksForTest(); resetCoreHealthChecksForTest(); }); - afterEach(async () => { - if (tmp !== undefined) { - await fs.rm(tmp, { recursive: true, force: true }); - tmp = undefined; - } - }); - it("registers the built-in health checks once", () => { registerCoreHealthChecks(); registerCoreHealthChecks(); @@ -88,39 +142,34 @@ describe("registerCoreHealthChecks", () => { ).toBe(false); }); - it("shows the repair-capable health check shape with skills readiness", async () => { - tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-skills-")); - const skillDir = join(tmp, "skills", "missing-tool"); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - join(skillDir, "SKILL.md"), - `--- -name: missing-tool -description: Missing tool -metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}}}' ---- - -# Missing tool -`, - "utf-8", - ); + it("converts unavailable skills into repair-capable health findings", async () => { + const unavailableSkill = createSkill(); const cfg: OpenClawConfig = { agents: { defaults: { - workspace: tmp, + workspace: "/tmp/openclaw-test-workspace", skills: ["missing-tool"], }, }, }; - const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/skills-readiness"); + const check = getCheck( + createCoreHealthChecks( + createDeps({ + async detectUnavailableSkills(): Promise { + return [unavailableSkill]; + }, + }), + ), + "core/doctor/skills-readiness", + ); - expect(check?.repair).toBeTypeOf("function"); + expect(check.repair).toBeTypeOf("function"); - const findings = await check?.detect({ + const findings = await check.detect({ mode: "lint", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg, - cwd: tmp, + cwd: "/tmp/openclaw-test-workspace", }); expect(findings).toContainEqual( expect.objectContaining({ @@ -130,23 +179,23 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}} }), ); await expect( - check?.detect( + check.detect( { mode: "fix", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg, - cwd: tmp, + cwd: "/tmp/openclaw-test-workspace", }, { paths: ["skills.entries.other-tool.enabled"] }, ), ).resolves.toEqual([]); await expect( - check?.detect( + check.detect( { mode: "fix", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg, - cwd: tmp, + cwd: "/tmp/openclaw-test-workspace", }, { paths: ["skills.entries.missing-tool.enabled"] }, ), @@ -156,14 +205,14 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}} }), ); - const repaired = await check?.repair?.( + const repaired = await check.repair?.( { mode: "fix", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg, - cwd: tmp, + cwd: "/tmp/openclaw-test-workspace", }, - findings ?? [], + findings, ); expect(repaired?.config?.skills?.entries?.["missing-tool"]).toEqual({ enabled: false }); expect(repaired?.changes).toContain("Disabled unavailable skill missing-tool."); @@ -177,11 +226,23 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}} }); it("converts security doctor warnings into health findings", async () => { - const check = CORE_HEALTH_CHECKS.find((entry) => entry.id === "core/doctor/security"); + const check = getCheck( + createCoreHealthChecks( + createDeps({ + async collectSecurityWarnings(): Promise { + return [ + '- CRITICAL: Gateway bound to "lan" (0.0.0.0) without authentication.', + '- WARNING: Gateway bound to "lan" (0.0.0.0).', + ]; + }, + }), + ), + "core/doctor/security", + ); - const findings = await check?.detect({ + const findings = await check.detect({ mode: "lint", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg: { gateway: { bind: "lan", @@ -199,25 +260,44 @@ metadata: '{"openclaw":{"requires":{"bins":["openclaw-test-missing-skill-bin"]}} message: expect.stringContaining("Gateway bound"), }), ); + expect(findings).toContainEqual( + expect.objectContaining({ + checkId: "core/doctor/security", + severity: "warning", + message: expect.stringContaining("Gateway bound"), + }), + ); }); it("converts workspace suggestions into info findings", async () => { - tmp = await fs.mkdtemp(join(tmpdir(), "openclaw-health-workspace-")); - const check = CORE_HEALTH_CHECKS.find( - (entry) => entry.id === "core/doctor/workspace-suggestions", + const check = getCheck( + createCoreHealthChecks( + createDeps({ + async collectWorkspaceSuggestionNotes(): Promise { + return [ + [ + "- Tip: back up the workspace in a private git repo (GitHub or GitLab).", + "- Keep ~/.openclaw out of git; it contains credentials and session history.", + ].join("\n"), + "Memory system not found in workspace.", + ]; + }, + }), + ), + "core/doctor/workspace-suggestions", ); - const findings = await check?.detect({ + const findings = await check.detect({ mode: "lint", - runtime: { log() {}, error() {}, exit() {} }, + runtime, cfg: { agents: { defaults: { - workspace: tmp, + workspace: "/tmp/openclaw-test-workspace", }, }, }, - cwd: tmp, + cwd: "/tmp/openclaw-test-workspace", }); expect(findings).toContainEqual( diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 88de4a27a57..1c3f8313c69 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -1,16 +1,13 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { buildWorkspaceSkillStatus, type SkillStatusEntry } from "../agents/skills-status.js"; +import type { SkillStatusEntry } from "../agents/skills-status.js"; import { detectLegacyClawdBrowserProfileResidue, maybeArchiveLegacyClawdBrowserProfileResidue, type LegacyClawdBrowserProfileResidue, } from "../commands/doctor-browser.js"; import { hasConfiguredCommandOwners } from "../commands/doctor-command-owner.js"; -import { - collectUnavailableAgentSkills, - disableUnavailableSkillsInConfig, -} from "../commands/doctor-skills.js"; +import { disableUnavailableSkillsInConfig } from "../commands/doctor-skills-core.js"; import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; @@ -21,6 +18,47 @@ import type { HealthCheck, HealthFinding } from "./health-checks.js"; const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profile-residue"; const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation"; +export type CoreHealthCheckDeps = { + readonly detectUnavailableSkills: (cfg: OpenClawConfig) => Promise; + readonly collectSecurityWarnings: (cfg: OpenClawConfig) => Promise; + readonly collectWorkspaceSuggestionNotes: (workspaceDir: string) => Promise; +}; + +async function detectUnavailableSkillsWithRuntime( + cfg: OpenClawConfig, +): Promise { + const runtime = await import("./doctor-core-checks.runtime.js"); + return runtime.detectUnavailableSkills(cfg); +} + +async function collectSecurityWarningsWithRuntime(cfg: OpenClawConfig): Promise { + const { collectSecurityWarnings } = await import("../commands/doctor-security.js"); + return collectSecurityWarnings(cfg); +} + +async function collectWorkspaceSuggestionNotesWithRuntime( + workspaceDir: string, +): Promise { + const { collectWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js"); + const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } = + await import("../commands/doctor-workspace.js"); + const notes: string[] = []; + const backupTip = collectWorkspaceBackupTip(workspaceDir); + if (backupTip) { + notes.push(backupTip); + } + if (await shouldSuggestMemorySystem(workspaceDir)) { + notes.push(MEMORY_SYSTEM_PROMPT); + } + return notes; +} + +const defaultCoreHealthCheckDeps: CoreHealthCheckDeps = { + detectUnavailableSkills: detectUnavailableSkillsWithRuntime, + collectSecurityWarnings: collectSecurityWarningsWithRuntime, + collectWorkspaceSuggestionNotes: collectWorkspaceSuggestionNotesWithRuntime, +}; + export function configValidationIssuesToHealthFindings( issues: readonly ConfigValidationIssue[], ): readonly HealthFinding[] { @@ -385,23 +423,24 @@ const claudeCliCheck: HealthCheck = { }, }; -const securityCheck: HealthCheck = { - id: "core/doctor/security", - kind: "core", - description: "Security posture checks produce structured findings.", - source: "doctor", - async detect(ctx) { - const { collectSecurityWarnings } = await import("../commands/doctor-security.js"); - const warnings = await collectSecurityWarnings(ctx.cfg); - return warnings.map((warning) => - noteTextToFinding({ - checkId: "core/doctor/security", - severity: warning.includes("CRITICAL") ? "error" : "warning", - text: warning, - }), - ); - }, -}; +function createSecurityCheck(deps: CoreHealthCheckDeps): HealthCheck { + return { + id: "core/doctor/security", + kind: "core", + description: "Security posture checks produce structured findings.", + source: "doctor", + async detect(ctx) { + const warnings = await deps.collectSecurityWarnings(ctx.cfg); + return warnings.map((warning) => + noteTextToFinding({ + checkId: "core/doctor/security", + severity: warning.includes("CRITICAL") ? "error" : "warning", + text: warning, + }), + ); + }, + }; +} const openAIOAuthTlsCheck: HealthCheck = { id: "core/doctor/oauth-tls", @@ -513,39 +552,41 @@ const workspaceStatusCheck: HealthCheck = { }, }; -const skillsReadinessCheck: HealthCheck = { - id: "core/doctor/skills-readiness", - kind: "core", - description: "Allowed skills are usable in the current runtime environment.", - source: "doctor", - async detect(ctx, scope) { - const unavailable = filterUnavailableSkillsForScope( - detectUnavailableSkills(ctx.cfg), - scope?.paths, - ); - return unavailable.map(unavailableSkillToFinding); - }, - async repair(ctx, findings) { - const unavailable = filterUnavailableSkillsForScope( - detectUnavailableSkills(ctx.cfg), - findings.map((finding) => finding.path), - ); - if (unavailable.length === 0) { - return { changes: [] }; - } - const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable); - return { - config: nextConfig, - changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`), - effects: unavailable.map((skill) => ({ - kind: "config" as const, - action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill", - target: skillReadinessPath(skill), - dryRunSafe: true, - })), - }; - }, -}; +function createSkillsReadinessCheck(deps: CoreHealthCheckDeps): HealthCheck { + return { + id: "core/doctor/skills-readiness", + kind: "core", + description: "Allowed skills are usable in the current runtime environment.", + source: "doctor", + async detect(ctx, scope) { + const unavailable = filterUnavailableSkillsForScope( + await deps.detectUnavailableSkills(ctx.cfg), + scope?.paths, + ); + return unavailable.map(unavailableSkillToFinding); + }, + async repair(ctx, findings) { + const unavailable = filterUnavailableSkillsForScope( + await deps.detectUnavailableSkills(ctx.cfg), + findings.map((finding) => finding.path), + ); + if (unavailable.length === 0) { + return { changes: [] }; + } + const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable); + return { + config: nextConfig, + changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`), + effects: unavailable.map((skill) => ({ + kind: "config" as const, + action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill", + target: skillReadinessPath(skill), + dryRunSafe: true, + })), + }; + }, + }; +} function unavailableSkillToFinding(skill: SkillStatusEntry): HealthFinding { return { @@ -674,54 +715,42 @@ const finalConfigValidationCheck: HealthCheck = { }, }; -const workspaceSuggestionsCheck: HealthCheck = { - id: "core/doctor/workspace-suggestions", - kind: "core", - description: - "Workspace backup and memory-system suggestions are captured as structured findings.", - source: "doctor", - async detect(ctx) { - const { collectWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js"); - const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } = - await import("../commands/doctor-workspace.js"); - const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)); - const findings: HealthFinding[] = []; - const backupTip = collectWorkspaceBackupTip(workspaceDir); - if (backupTip) { - findings.push( +function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck { + return { + id: "core/doctor/workspace-suggestions", + kind: "core", + description: + "Workspace backup and memory-system suggestions are captured as structured findings.", + source: "doctor", + async detect(ctx) { + const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)); + const notes = await deps.collectWorkspaceSuggestionNotes(workspaceDir); + return notes.map((text) => noteTextToFinding({ checkId: "core/doctor/workspace-suggestions", severity: "info", - text: backupTip, + text, }), ); - } - if (await shouldSuggestMemorySystem(workspaceDir)) { - findings.push( - noteTextToFinding({ - checkId: "core/doctor/workspace-suggestions", - severity: "info", - text: MEMORY_SYSTEM_PROMPT, - }), - ); - } - return findings; - }, -}; + }, + }; +} -const convertedWorkflowChecks: readonly HealthCheck[] = [ - claudeCliCheck, - gatewayAuthCheck, - legacyStateCheck, - legacyWhatsAppCrontabCheck, - gatewayPlatformNotesCheck, - securityCheck, - browserCheck, - openAIOAuthTlsCheck, - hooksModelCheck, - bootstrapSizeCheck, - workspaceSuggestionsCheck, -]; +function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] { + return [ + claudeCliCheck, + gatewayAuthCheck, + legacyStateCheck, + legacyWhatsAppCrontabCheck, + gatewayPlatformNotesCheck, + createSecurityCheck(deps), + browserCheck, + openAIOAuthTlsCheck, + hooksModelCheck, + bootstrapSizeCheck, + createWorkspaceSuggestionsCheck(deps), + ]; +} let registered = false; @@ -729,15 +758,9 @@ export function registerCoreHealthChecks(): void { if (registered) { return; } - registerHealthCheck(gatewayConfigCheck); - for (const check of convertedWorkflowChecks) { + for (const check of CORE_HEALTH_CHECKS) { registerHealthCheck(check); } - registerHealthCheck(commandOwnerCheck); - registerHealthCheck(workspaceStatusCheck); - registerHealthCheck(skillsReadinessCheck); - registerHealthCheck(browserClawdProfileResidueCheck); - registerHealthCheck(finalConfigValidationCheck); registered = true; } @@ -745,26 +768,22 @@ export function resetCoreHealthChecksForTest(): void { registered = false; } -export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = [ - gatewayConfigCheck, - ...convertedWorkflowChecks, - commandOwnerCheck, - workspaceStatusCheck, - skillsReadinessCheck, - browserClawdProfileResidueCheck, - finalConfigValidationCheck, -]; - -function detectUnavailableSkills(cfg: OpenClawConfig): SkillStatusEntry[] { - const agentId = resolveDefaultAgentId(cfg); - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const report = buildWorkspaceSkillStatus(workspaceDir, { - config: cfg, - agentId, - }); - return collectUnavailableAgentSkills(report); +export function createCoreHealthChecks( + deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps, +): readonly HealthCheck[] { + return [ + gatewayConfigCheck, + ...createConvertedWorkflowChecks(deps), + commandOwnerCheck, + workspaceStatusCheck, + createSkillsReadinessCheck(deps), + browserClawdProfileResidueCheck, + finalConfigValidationCheck, + ]; } +export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks(); + function formatMissingSkillSummary(skill: SkillStatusEntry): string { const missing: string[] = []; if (skill.missing.bins.length > 0) {