Skip to content

Commit dce1b0a

Browse files
authored
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 f766230. - `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`
1 parent ae9de77 commit dce1b0a

4 files changed

Lines changed: 194 additions & 0 deletions

File tree

src/commands/doctor-heartbeat-template-repair.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import {
66
analyzeHeartbeatTemplateForRepair,
7+
collectHeartbeatTemplateHealthFindings,
78
maybeRepairHeartbeatTemplate,
89
} from "./doctor-heartbeat-template-repair.js";
910

@@ -189,6 +190,71 @@ Add short tasks below the comments only when you want the agent to check somethi
189190
);
190191
});
191192

193+
it("collects a finding for pure dirty templates", async () => {
194+
const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown
195+
# Keep this file empty (or with only comments) to skip heartbeat API calls.
196+
197+
# Add tasks below when you want the agent to check something periodically.
198+
\`\`\`
199+
`);
200+
201+
const findings = await collectHeartbeatTemplateHealthFindings({
202+
agents: { defaults: { workspace: workspaceDir } },
203+
});
204+
205+
expect(findings).toEqual([
206+
expect.objectContaining({
207+
checkId: "core/doctor/heartbeat-template",
208+
severity: "warning",
209+
path: heartbeatPath,
210+
requirement: "legacy-template",
211+
fixHint: expect.stringContaining("openclaw doctor --fix"),
212+
}),
213+
]);
214+
});
215+
216+
it("collects a manual finding when dirty templates include user content", async () => {
217+
const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown
218+
# Keep this file empty (or with only comments) to skip heartbeat API calls.
219+
220+
# Add tasks below when you want the agent to check something periodically.
221+
\`\`\`
222+
223+
- Check email
224+
`);
225+
226+
const findings = await collectHeartbeatTemplateHealthFindings({
227+
agents: { defaults: { workspace: workspaceDir } },
228+
});
229+
230+
expect(findings).toEqual([
231+
expect.objectContaining({
232+
checkId: "core/doctor/heartbeat-template",
233+
severity: "warning",
234+
path: heartbeatPath,
235+
requirement: "legacy-template-with-custom-content",
236+
fixHint: expect.stringContaining("Remove the fenced template"),
237+
}),
238+
]);
239+
});
240+
241+
it("returns no findings for clean templates or missing heartbeat files", async () => {
242+
const { workspaceDir } = await makeWorkspaceWithHeartbeat(`# Keep this file empty.
243+
`);
244+
const missingWorkspaceDir = await makeTempRoot();
245+
246+
await expect(
247+
collectHeartbeatTemplateHealthFindings({
248+
agents: { defaults: { workspace: workspaceDir } },
249+
}),
250+
).resolves.toEqual([]);
251+
await expect(
252+
collectHeartbeatTemplateHealthFindings({
253+
agents: { defaults: { workspace: missingWorkspaceDir } },
254+
}),
255+
).resolves.toEqual([]);
256+
});
257+
192258
it("rewrites pure dirty templates to the clean runtime template", async () => {
193259
const { workspaceDir, heartbeatPath } = await makeWorkspaceWithHeartbeat(`\`\`\`markdown
194260
# Keep this file empty (or with only comments) to skip heartbeat API calls.

src/commands/doctor-heartbeat-template-repair.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
66
import { resolveWorkspaceTemplateDir } from "../agents/workspace-templates.js";
77
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
88
import type { OpenClawConfig } from "../config/types.openclaw.js";
9+
import type { HealthFinding } from "../flows/health-checks.js";
910
import { formatErrorMessage } from "../infra/errors.js";
1011
import { writeTextAtomic } from "../infra/json-files.js";
1112
import { shortenHomePath } from "../utils.js";
1213

14+
const HEARTBEAT_TEMPLATE_CHECK_ID = "core/doctor/heartbeat-template";
15+
1316
const LEGACY_HEARTBEAT_PROSE_TEMPLATE = [
1417
"# HEARTBEAT.md",
1518
"Keep this file empty unless you want a tiny checklist. Keep it small.",
@@ -126,6 +129,67 @@ async function readCleanHeartbeatTemplate(): Promise<string> {
126129
return await fs.readFile(templatePath, "utf-8");
127130
}
128131

132+
function heartbeatTemplateAnalysisToHealthFinding(
133+
heartbeatPath: string,
134+
analysis: Exclude<HeartbeatTemplateRepairAnalysis, { status: "clean" }>,
135+
): HealthFinding {
136+
if (analysis.status === "dirty-template-with-custom-content") {
137+
return {
138+
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
139+
severity: "warning",
140+
message:
141+
"HEARTBEAT.md contains an older heartbeat template wrapper plus custom or unrecognized content.",
142+
path: heartbeatPath,
143+
requirement: "legacy-template-with-custom-content",
144+
fixHint: "Remove the fenced template and Related lines manually if they are not intentional.",
145+
};
146+
}
147+
return {
148+
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
149+
severity: "warning",
150+
message: "HEARTBEAT.md contains an older heartbeat documentation template.",
151+
path: heartbeatPath,
152+
requirement: "legacy-template",
153+
fixHint: 'Run "openclaw doctor --fix" to replace it with the clean heartbeat template.',
154+
};
155+
}
156+
157+
/** Collects read-only structured findings for legacy HEARTBEAT.md template wrappers. */
158+
export async function collectHeartbeatTemplateHealthFindings(
159+
cfg: OpenClawConfig,
160+
deps?: {
161+
readFile?: (filePath: string) => Promise<string>;
162+
},
163+
): Promise<readonly HealthFinding[]> {
164+
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
165+
const heartbeatPath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
166+
const readFile = deps?.readFile ?? ((filePath: string) => fs.readFile(filePath, "utf-8"));
167+
let content: string;
168+
try {
169+
content = await readFile(heartbeatPath);
170+
} catch (error) {
171+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
172+
return [];
173+
}
174+
return [
175+
{
176+
checkId: HEARTBEAT_TEMPLATE_CHECK_ID,
177+
severity: "warning",
178+
message: `Could not inspect HEARTBEAT.md: ${formatErrorMessage(error)}`,
179+
path: heartbeatPath,
180+
requirement: "inspect-failed",
181+
fixHint: "Check file permissions, then rerun doctor.",
182+
},
183+
];
184+
}
185+
186+
const analysis = analyzeHeartbeatTemplateForRepair(content);
187+
if (analysis.status === "clean") {
188+
return [];
189+
}
190+
return [heartbeatTemplateAnalysisToHealthFinding(heartbeatPath, analysis)];
191+
}
192+
129193
/** Replaces known dirty heartbeat templates with the clean runtime template when repair is enabled. */
130194
export async function maybeRepairHeartbeatTemplate(params: {
131195
cfg: OpenClawConfig;

src/flows/doctor-health-contributions.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ const mocks = vi.hoisted(() => ({
8484
noteWorkspaceStatus: vi.fn(),
8585
collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]),
8686
collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []),
87+
collectHeartbeatTemplateHealthFindings: vi.fn(async () => [] as unknown[]),
88+
maybeRepairHeartbeatTemplate: vi.fn().mockResolvedValue(undefined),
8789
collectDevicePairingHealthFindings: vi.fn(async () => []),
8890
scanConfiguredChannelPluginBlockers: vi.fn(
8991
(): Array<{ channelId: string; pluginId: string; reason: string }> => [],
@@ -303,6 +305,11 @@ vi.mock("../commands/doctor-disk-space.js", () => ({
303305
collectDiskSpaceHealthFindings: mocks.collectDiskSpaceHealthFindings,
304306
}));
305307

308+
vi.mock("../commands/doctor-heartbeat-template-repair.js", () => ({
309+
collectHeartbeatTemplateHealthFindings: mocks.collectHeartbeatTemplateHealthFindings,
310+
maybeRepairHeartbeatTemplate: mocks.maybeRepairHeartbeatTemplate,
311+
}));
312+
306313
vi.mock("../commands/doctor-device-pairing.js", () => ({
307314
collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings,
308315
noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined),
@@ -498,6 +505,10 @@ describe("doctor health contributions", () => {
498505
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
499506
mocks.collectDiskSpaceHealthFindings.mockReset();
500507
mocks.collectDiskSpaceHealthFindings.mockReturnValue([]);
508+
mocks.collectHeartbeatTemplateHealthFindings.mockReset();
509+
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValue([]);
510+
mocks.maybeRepairHeartbeatTemplate.mockReset();
511+
mocks.maybeRepairHeartbeatTemplate.mockResolvedValue(undefined);
501512
mocks.collectDevicePairingHealthFindings.mockReset();
502513
mocks.collectDevicePairingHealthFindings.mockResolvedValue([]);
503514
mocks.scanConfiguredChannelPluginBlockers.mockReset();
@@ -1018,6 +1029,47 @@ describe("doctor health contributions", () => {
10181029
);
10191030
});
10201031

1032+
it("keeps heartbeat template lint opt-in for default lint selection", async () => {
1033+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1034+
const heartbeatTemplateCheck = contributionChecks.find(
1035+
(check) => check.id === "core/doctor/heartbeat-template",
1036+
);
1037+
expect(heartbeatTemplateCheck).toMatchObject({ defaultEnabled: false });
1038+
expect(heartbeatTemplateCheck).toBeDefined();
1039+
1040+
const ctx = {
1041+
cfg: { agents: { defaults: { workspace: "/tmp/openclaw-workspace" } } },
1042+
mode: "lint",
1043+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1044+
} as const;
1045+
const checks = [heartbeatTemplateCheck!];
1046+
1047+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
1048+
checksRun: 0,
1049+
checksSkipped: 1,
1050+
});
1051+
expect(mocks.collectHeartbeatTemplateHealthFindings).not.toHaveBeenCalled();
1052+
1053+
mocks.collectHeartbeatTemplateHealthFindings.mockResolvedValueOnce([
1054+
{
1055+
checkId: "core/doctor/heartbeat-template",
1056+
severity: "warning",
1057+
message: "HEARTBEAT.md contains an older heartbeat documentation template.",
1058+
path: "/tmp/openclaw-workspace/HEARTBEAT.md",
1059+
requirement: "legacy-template",
1060+
},
1061+
]);
1062+
1063+
await expect(
1064+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/heartbeat-template"] }),
1065+
).resolves.toMatchObject({
1066+
checksRun: 1,
1067+
checksSkipped: 0,
1068+
findings: [expect.objectContaining({ checkId: "core/doctor/heartbeat-template" })],
1069+
});
1070+
expect(mocks.collectHeartbeatTemplateHealthFindings).toHaveBeenCalledWith(ctx.cfg);
1071+
});
1072+
10211073
it("preserves allow-exec Gateway SecretRef resolution in auth health", async () => {
10221074
const contribution = requireDoctorContribution("doctor:gateway-auth");
10231075
const ctx = {
@@ -1220,6 +1272,8 @@ describe("doctor health contributions", () => {
12201272
expect(contributionIds).toContain("core/doctor/plugin-registry");
12211273
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
12221274
expect(contributionIds).toContain("core/doctor/disk-space");
1275+
expect(contributionIds).toContain("core/doctor/heartbeat-template");
1276+
expect(contributionIds).toContain("core/doctor/disk-space");
12231277
expect(contributionIds).toContain("core/doctor/device-pairing");
12241278
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
12251279
expect(contributionIds).toContain("core/doctor/tool-result-cap");

src/flows/doctor-health-contributions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,16 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
18391839
createDoctorHealthContribution({
18401840
id: "doctor:heartbeat-template-repair",
18411841
label: "Heartbeat template repair",
1842+
healthChecks: {
1843+
id: "core/doctor/heartbeat-template",
1844+
description: "Legacy HEARTBEAT.md documentation templates are findings.",
1845+
defaultEnabled: false,
1846+
async detect(ctx) {
1847+
const { collectHeartbeatTemplateHealthFindings } =
1848+
await import("../commands/doctor-heartbeat-template-repair.js");
1849+
return await collectHeartbeatTemplateHealthFindings(ctx.cfg);
1850+
},
1851+
},
18421852
run: runHeartbeatTemplateRepairHealth,
18431853
}),
18441854
createDoctorHealthContribution({

0 commit comments

Comments
 (0)