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 f76623016c.
- `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`
This commit is contained in:
Gio Della-Libera
2026-07-02 17:40:17 -07:00
committed by GitHub
parent ae9de77665
commit dce1b0a87a
4 changed files with 194 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<string> {
return await fs.readFile(templatePath, "utf-8");
}
function heartbeatTemplateAnalysisToHealthFinding(
heartbeatPath: string,
analysis: Exclude<HeartbeatTemplateRepairAnalysis, { status: "clean" }>,
): 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<string>;
},
): Promise<readonly HealthFinding[]> {
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;

View File

@@ -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");

View File

@@ -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({