From dce1b0a87afb64cbc54b750f5dc2fb2ce6b34bbe Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 2 Jul 2026 17:40:17 -0700 Subject: [PATCH] Expose heartbeat template doctor lint findings (#98400) Expose default-disabled Doctor lint findings for legacy HEARTBEAT.md documentation-template wrappers. - Reuses the existing heartbeat template analyzer for structured `HealthFinding` output. - Keeps replacement/mutation in the existing legacy `doctor --fix` path; no structured dry-run repair hook in this PR. - Default `doctor --lint` remains unchanged; explicit `--only core/doctor/heartbeat-template` and `--all` can select it. Validation: - Hosted PR checks green on head f76623016cace286895c53f6641cfab24603b5c6. - `node scripts/run-vitest.mjs src/commands/doctor-heartbeat-template-repair.test.ts src/flows/doctor-health-contributions.test.ts` (73 passed) - changed-file `pnpm exec oxfmt --check` - changed-file `pnpm exec oxlint` - `node scripts/plugin-sdk-surface-report.mjs --check` - `git diff --check` --- .../doctor-heartbeat-template-repair.test.ts | 66 +++++++++++++++++++ .../doctor-heartbeat-template-repair.ts | 64 ++++++++++++++++++ src/flows/doctor-health-contributions.test.ts | 54 +++++++++++++++ src/flows/doctor-health-contributions.ts | 10 +++ 4 files changed, 194 insertions(+) diff --git a/src/commands/doctor-heartbeat-template-repair.test.ts b/src/commands/doctor-heartbeat-template-repair.test.ts index 9333ada806a0..40d5f74296d1 100644 --- a/src/commands/doctor-heartbeat-template-repair.test.ts +++ b/src/commands/doctor-heartbeat-template-repair.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { analyzeHeartbeatTemplateForRepair, + collectHeartbeatTemplateHealthFindings, maybeRepairHeartbeatTemplate, } from "./doctor-heartbeat-template-repair.js"; @@ -189,6 +190,71 @@ Add short tasks below the comments only when you want the agent to check somethi ); }); + it("collects a finding for pure dirty templates", async () => { + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` +`); + + const findings = await collectHeartbeatTemplateHealthFindings({ + agents: { defaults: { workspace: workspaceDir } }, + }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/heartbeat-template", + severity: "warning", + path: heartbeatPath, + requirement: "legacy-template", + fixHint: expect.stringContaining("openclaw doctor --fix"), + }), + ]); + }); + + it("collects a manual finding when dirty templates include user content", async () => { + const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown +# Keep this file empty (or with only comments) to skip heartbeat API calls. + +# Add tasks below when you want the agent to check something periodically. +\`\`\` + +- Check email +`); + + const findings = await collectHeartbeatTemplateHealthFindings({ + agents: { defaults: { workspace: workspaceDir } }, + }); + + expect(findings).toEqual([ + expect.objectContaining({ + checkId: "core/doctor/heartbeat-template", + severity: "warning", + path: heartbeatPath, + requirement: "legacy-template-with-custom-content", + fixHint: expect.stringContaining("Remove the fenced template"), + }), + ]); + }); + + it("returns no findings for clean templates or missing heartbeat files", async () => { + const { workspaceDir } = await makeWorkspaceWithHeartbeat(`# Keep this file empty. +`); + const missingWorkspaceDir = await makeTempRoot(); + + await expect( + collectHeartbeatTemplateHealthFindings({ + agents: { defaults: { workspace: workspaceDir } }, + }), + ).resolves.toEqual([]); + await expect( + collectHeartbeatTemplateHealthFindings({ + agents: { defaults: { workspace: missingWorkspaceDir } }, + }), + ).resolves.toEqual([]); + }); + it("rewrites pure dirty templates to the clean runtime template", async () => { const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown # Keep this file empty (or with only comments) to skip heartbeat API calls. diff --git a/src/commands/doctor-heartbeat-template-repair.ts b/src/commands/doctor-heartbeat-template-repair.ts index 5a880a593b03..f6245c44fdc4 100644 --- a/src/commands/doctor-heartbeat-template-repair.ts +++ b/src/commands/doctor-heartbeat-template-repair.ts @@ -6,10 +6,13 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveWorkspaceTemplateDir } from "../agents/workspace-templates.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { HealthFinding } from "../flows/health-checks.js"; import { formatErrorMessage } from "../infra/errors.js"; import { writeTextAtomic } from "../infra/json-files.js"; import { shortenHomePath } from "../utils.js"; +const HEARTBEAT_TEMPLATE_CHECK_ID = "core/doctor/heartbeat-template"; + const LEGACY_HEARTBEAT_PROSE_TEMPLATE = [ "# HEARTBEAT.md", "Keep this file empty unless you want a tiny checklist. Keep it small.", @@ -126,6 +129,67 @@ async function readCleanHeartbeatTemplate(): Promise { return await fs.readFile(templatePath, "utf-8"); } +function heartbeatTemplateAnalysisToHealthFinding( + heartbeatPath: string, + analysis: Exclude, +): HealthFinding { + if (analysis.status === "dirty-template-with-custom-content") { + return { + checkId: HEARTBEAT_TEMPLATE_CHECK_ID, + severity: "warning", + message: + "HEARTBEAT.md contains an older heartbeat template wrapper plus custom or unrecognized content.", + path: heartbeatPath, + requirement: "legacy-template-with-custom-content", + fixHint: "Remove the fenced template and Related lines manually if they are not intentional.", + }; + } + return { + checkId: HEARTBEAT_TEMPLATE_CHECK_ID, + severity: "warning", + message: "HEARTBEAT.md contains an older heartbeat documentation template.", + path: heartbeatPath, + requirement: "legacy-template", + fixHint: 'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.', + }; +} + +/** Collects read-only structured findings for legacy HEARTBEAT.md template wrappers. */ +export async function collectHeartbeatTemplateHealthFindings( + cfg: OpenClawConfig, + deps?: { + readFile?: (filePath: string) => Promise; + }, +): Promise { + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); + const readFile = deps?.readFile ?? ((filePath: string) => fs.readFile(filePath, "utf-8")); + let content: string; + try { + content = await readFile(heartbeatPath); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return []; + } + return [ + { + checkId: HEARTBEAT_TEMPLATE_CHECK_ID, + severity: "warning", + message: `Could not inspect HEARTBEAT.md: ${formatErrorMessage(error)}`, + path: heartbeatPath, + requirement: "inspect-failed", + fixHint: "Check file permissions, then rerun doctor.", + }, + ]; + } + + const analysis = analyzeHeartbeatTemplateForRepair(content); + if (analysis.status === "clean") { + return []; + } + return [heartbeatTemplateAnalysisToHealthFinding(heartbeatPath, analysis)]; +} + /** Replaces known dirty heartbeat templates with the clean runtime template when repair is enabled. */ export async function maybeRepairHeartbeatTemplate(params: { cfg: OpenClawConfig; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b00ae5dcfe59..914e4dccf10e 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -84,6 +84,8 @@ const mocks = vi.hoisted(() => ({ noteWorkspaceStatus: vi.fn(), collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]), collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []), + collectHeartbeatTemplateHealthFindings: vi.fn(async () => [] as unknown[]), + maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined), collectDevicePairingHealthFindings: vi.fn(async () => []), scanConfiguredChannelPluginBlockers: vi.fn( (): Array<{ channelId: string; pluginId: string; reason: string }> => [], @@ -303,6 +305,11 @@ vi.mock("../commands/doctor-disk-space.js", () => ({ collectDiskSpaceHealthFindings: mocks.collectDiskSpaceHealthFindings, })); +vi.mock("../commands/doctor-heartbeat-template-repair.js", () => ({ + collectHeartbeatTemplateHealthFindings: mocks.collectHeartbeatTemplateHealthFindings, + maybeRepairHeartbeatTemplate: mocks.maybeRepairHeartbeatTemplate, +})); + vi.mock("../commands/doctor-device-pairing.js", () => ({ collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings, noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined), @@ -498,6 +505,10 @@ describe("doctor health contributions", () => { mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]); mocks.collectDiskSpaceHealthFindings.mockReset(); mocks.collectDiskSpaceHealthFindings.mockReturnValue([]); + mocks.collectHeartbeatTemplateHealthFindings.mockReset(); + mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValue([]); + mocks.maybeRepairHeartbeatTemplate.mockReset(); + mocks.maybeRepairHeartbeatTemplate.mockResolvedValue(undefined); mocks.collectDevicePairingHealthFindings.mockReset(); mocks.collectDevicePairingHealthFindings.mockResolvedValue([]); mocks.scanConfiguredChannelPluginBlockers.mockReset(); @@ -1018,6 +1029,47 @@ describe("doctor health contributions", () => { ); }); + it("keeps heartbeat template lint opt-in for default lint selection", async () => { + const contributionChecks = await resolveDoctorContributionHealthChecks(); + const heartbeatTemplateCheck = contributionChecks.find( + (check) => check.id === "core/doctor/heartbeat-template", + ); + expect(heartbeatTemplateCheck).toMatchObject({ defaultEnabled: false }); + expect(heartbeatTemplateCheck).toBeDefined(); + + const ctx = { + cfg: { agents: { defaults: { workspace: "/tmp/openclaw-workspace" } } }, + mode: "lint", + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + } as const; + const checks = [heartbeatTemplateCheck!]; + + await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ + checksRun: 0, + checksSkipped: 1, + }); + expect(mocks.collectHeartbeatTemplateHealthFindings).not.toHaveBeenCalled(); + + mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValueOnce([ + { + checkId: "core/doctor/heartbeat-template", + severity: "warning", + message: "HEARTBEAT.md contains an older heartbeat documentation template.", + path: "/tmp/openclaw-workspace/HEARTBEAT.md", + requirement: "legacy-template", + }, + ]); + + await expect( + runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/heartbeat-template"] }), + ).resolves.toMatchObject({ + checksRun: 1, + checksSkipped: 0, + findings: [expect.objectContaining({ checkId: "core/doctor/heartbeat-template" })], + }); + expect(mocks.collectHeartbeatTemplateHealthFindings).toHaveBeenCalledWith(ctx.cfg); + }); + it("preserves allow-exec Gateway SecretRef resolution in auth health", async () => { const contribution = requireDoctorContribution("doctor:gateway-auth"); const ctx = { @@ -1220,6 +1272,8 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/plugin-registry"); expect(contributionIds).toContain("core/doctor/configured-plugin-installs"); expect(contributionIds).toContain("core/doctor/disk-space"); + expect(contributionIds).toContain("core/doctor/heartbeat-template"); + expect(contributionIds).toContain("core/doctor/disk-space"); expect(contributionIds).toContain("core/doctor/device-pairing"); expect(contributionIds).toContain("core/doctor/channel-plugin-blockers"); expect(contributionIds).toContain("core/doctor/tool-result-cap"); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 993ff2cf695b..ecc9d3bd0da9 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -1839,6 +1839,16 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { createDoctorHealthContribution({ id: "doctor:heartbeat-template-repair", label: "Heartbeat template repair", + healthChecks: { + id: "core/doctor/heartbeat-template", + description: "Legacy HEARTBEAT.md documentation templates are findings.", + defaultEnabled: false, + async detect(ctx) { + const { collectHeartbeatTemplateHealthFindings } = + await import("../commands/doctor-heartbeat-template-repair.js"); + return await collectHeartbeatTemplateHealthFindings(ctx.cfg); + }, + }, run: runHeartbeatTemplateRepairHealth, }), createDoctorHealthContribution({