mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 18:16:02 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user