Skip to content

Commit 285c629

Browse files
authored
Expose disk space doctor lint findings (#98391)
* Expose disk space doctor lint findings * test(doctor): type disk-space health finding mock
1 parent 8a3935f commit 285c629

4 files changed

Lines changed: 195 additions & 21 deletions

File tree

src/commands/doctor-disk-space.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Doctor disk-space tests cover byte formatting, warning generation, and note rendering.
22
import { describe, expect, it, vi } from "vitest";
3-
import { buildDiskSpaceWarnings, formatBytes, noteDiskSpace } from "./doctor-disk-space.js";
3+
import {
4+
buildDiskSpaceWarnings,
5+
collectDiskSpaceHealthFindings,
6+
formatBytes,
7+
noteDiskSpace,
8+
} from "./doctor-disk-space.js";
49

510
vi.mock("../../packages/terminal-core/src/note.js", () => ({
611
note: vi.fn(),
@@ -164,3 +169,61 @@ describe("noteDiskSpace", () => {
164169
expect(mockNote).not.toHaveBeenCalled();
165170
});
166171
});
172+
173+
describe("collectDiskSpaceHealthFindings", () => {
174+
it("returns a low-space warning finding", () => {
175+
const findings = collectDiskSpaceHealthFindings({ gateway: { mode: "local" } } as never, {
176+
env: { HOME: "/home/test" },
177+
readDiskSpace: () => ({ availableBytes: 300 * 1024 * 1024 }),
178+
});
179+
180+
expect(findings).toEqual([
181+
expect.objectContaining({
182+
checkId: "core/doctor/disk-space",
183+
severity: "warning",
184+
message: "Low disk space: 300 MB free on the partition containing /home/test/.openclaw.",
185+
path: "/home/test/.openclaw",
186+
target: "300 MB",
187+
requirement: "low-free-space",
188+
fixHint: expect.stringContaining("prevent future config/session write failures"),
189+
}),
190+
]);
191+
});
192+
193+
it("returns a critical-space warning finding", () => {
194+
const findings = collectDiskSpaceHealthFindings({ gateway: { mode: "local" } } as never, {
195+
env: { HOME: "/home/test" },
196+
readDiskSpace: () => ({ availableBytes: 50 * 1024 * 1024 }),
197+
});
198+
199+
expect(findings).toEqual([
200+
expect.objectContaining({
201+
checkId: "core/doctor/disk-space",
202+
severity: "warning",
203+
message: "CRITICAL: only 50 MB free on the partition containing /home/test/.openclaw.",
204+
path: "/home/test/.openclaw",
205+
target: "50 MB",
206+
requirement: "critical-free-space",
207+
fixHint: expect.stringContaining("avoid data loss"),
208+
}),
209+
]);
210+
});
211+
212+
it("returns no finding when space is sufficient", () => {
213+
const findings = collectDiskSpaceHealthFindings({ gateway: { mode: "local" } } as never, {
214+
env: { HOME: "/home/test" },
215+
readDiskSpace: () => ({ availableBytes: 10 * 1024 * 1024 * 1024 }),
216+
});
217+
218+
expect(findings).toEqual([]);
219+
});
220+
221+
it("returns no finding when disk space cannot be read", () => {
222+
const findings = collectDiskSpaceHealthFindings({ gateway: { mode: "local" } } as never, {
223+
env: { HOME: "/home/test" },
224+
readDiskSpace: () => null,
225+
});
226+
227+
expect(findings).toEqual([]);
228+
});
229+
});

src/commands/doctor-disk-space.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import os from "node:os";
33
import { note } from "../../packages/terminal-core/src/note.js";
44
import type { OpenClawConfig } from "../config/config.js";
55
import { resolveStateDir } from "../config/paths.js";
6+
import type { HealthFinding } from "../flows/health-checks.js";
67
import { tryReadDiskSpace } from "../infra/disk-space.js";
78
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
89
import { shortenHomePath } from "../utils.js";
910

11+
const DISK_SPACE_CHECK_ID = "core/doctor/disk-space";
12+
1013
// 100 MB — below this, config writes and session transcripts are likely to
1114
// fail silently, causing data loss.
1215
const CRITICAL_BYTES = 100 * 1024 * 1024;
@@ -65,6 +68,67 @@ export function buildDiskSpaceWarnings(params: {
6568
return warnings;
6669
}
6770

71+
function collectDiskSpaceWarnings(params: {
72+
env?: NodeJS.ProcessEnv;
73+
readDiskSpace?: (targetPath: string) => { availableBytes: number } | null;
74+
}): { availableBytes: number; stateDir: string; warnings: readonly string[] } | null {
75+
const env = params.env ?? process.env;
76+
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
77+
const stateDir = resolveStateDir(env, homedir);
78+
79+
const readDiskSpace = params.readDiskSpace ?? tryReadDiskSpace;
80+
const snapshot = readDiskSpace(stateDir);
81+
// If we cannot determine free space (no existing ancestor, unsupported FS,
82+
// or permission error), skip silently — other contributions already
83+
// handle missing directories.
84+
if (!snapshot) {
85+
return null;
86+
}
87+
88+
const displayStateDir = shortenHomePath(stateDir);
89+
const warnings = buildDiskSpaceWarnings({
90+
availableBytes: snapshot.availableBytes,
91+
displayStateDir,
92+
});
93+
94+
return {
95+
availableBytes: snapshot.availableBytes,
96+
stateDir,
97+
warnings,
98+
};
99+
}
100+
101+
/** Collects read-only structured findings for low disk space around the state directory. */
102+
export function collectDiskSpaceHealthFindings(
103+
_cfg: OpenClawConfig, // reserved for API consistency with other Doctor contributions
104+
deps?: {
105+
env?: NodeJS.ProcessEnv;
106+
readDiskSpace?: (targetPath: string) => { availableBytes: number } | null;
107+
},
108+
): readonly HealthFinding[] {
109+
const result = collectDiskSpaceWarnings({
110+
env: deps?.env,
111+
readDiskSpace: deps?.readDiskSpace,
112+
});
113+
if (!result || result.warnings.length === 0) {
114+
return [];
115+
}
116+
117+
const [message, ...details] = result.warnings;
118+
return [
119+
{
120+
checkId: DISK_SPACE_CHECK_ID,
121+
severity: "warning",
122+
message: message.replace(/^- /, ""),
123+
path: result.stateDir,
124+
target: formatBytes(result.availableBytes),
125+
requirement:
126+
result.availableBytes < CRITICAL_BYTES ? "critical-free-space" : "low-free-space",
127+
fixHint: details.map((line) => line.replace(/^- /, "")).join(" "),
128+
},
129+
];
130+
}
131+
68132
/**
69133
* Doctor health contribution: check free disk space on the partition that
70134
* holds the state directory and warn when it drops below safe thresholds.
@@ -85,26 +149,13 @@ export function noteDiskSpace(
85149
readDiskSpace?: (targetPath: string) => { availableBytes: number } | null;
86150
},
87151
): void {
88-
const env = deps?.env ?? process.env;
89-
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
90-
const stateDir = resolveStateDir(env, homedir);
91-
92-
const readDiskSpace = deps?.readDiskSpace ?? tryReadDiskSpace;
93-
const snapshot = readDiskSpace(stateDir);
94-
// If we cannot determine free space (no existing ancestor, unsupported FS,
95-
// or permission error), skip silently — other contributions already
96-
// handle missing directories.
97-
if (!snapshot) {
152+
const result = collectDiskSpaceWarnings({
153+
env: deps?.env,
154+
readDiskSpace: deps?.readDiskSpace,
155+
});
156+
if (!result || result.warnings.length === 0) {
98157
return;
99158
}
100159

101-
const displayStateDir = shortenHomePath(stateDir);
102-
const warnings = buildDiskSpaceWarnings({
103-
availableBytes: snapshot.availableBytes,
104-
displayStateDir,
105-
});
106-
107-
if (warnings.length > 0) {
108-
note(warnings.join("\n"), "Disk space");
109-
}
160+
note(result.warnings.join("\n"), "Disk space");
110161
}

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
shouldSkipLegacyUpdateDoctorConfigWrite,
1111
} from "./doctor-health-contributions.js";
1212
import { runDoctorLintChecks } from "./doctor-lint-flow.js";
13-
import type { HealthCheck } from "./health-checks.js";
13+
import type { HealthCheck, HealthFinding } from "./health-checks.js";
1414

1515
const mocks = vi.hoisted(() => ({
1616
maybeRunConfiguredPluginInstallReleaseStep: vi.fn(),
@@ -83,6 +83,7 @@ const mocks = vi.hoisted(() => ({
8383
gatherDaemonStatus: vi.fn(),
8484
noteWorkspaceStatus: vi.fn(),
8585
collectWorkspaceStatusHealthFindings: vi.fn().mockResolvedValue([]),
86+
collectDiskSpaceHealthFindings: vi.fn((): readonly HealthFinding[] => []),
8687
collectDevicePairingHealthFindings: vi.fn(async () => []),
8788
scanConfiguredChannelPluginBlockers: vi.fn(
8889
(): Array<{ channelId: string; pluginId: string; reason: string }> => [],
@@ -297,6 +298,11 @@ vi.mock("../commands/doctor-workspace-status.js", () => ({
297298
collectWorkspaceStatusHealthFindings: mocks.collectWorkspaceStatusHealthFindings,
298299
}));
299300

301+
vi.mock("../commands/doctor-disk-space.js", () => ({
302+
noteDiskSpace: vi.fn(),
303+
collectDiskSpaceHealthFindings: mocks.collectDiskSpaceHealthFindings,
304+
}));
305+
300306
vi.mock("../commands/doctor-device-pairing.js", () => ({
301307
collectDevicePairingHealthFindings: mocks.collectDevicePairingHealthFindings,
302308
noteDevicePairingHealth: vi.fn().mockResolvedValue(undefined),
@@ -490,6 +496,8 @@ describe("doctor health contributions", () => {
490496
mocks.noteWorkspaceStatus.mockReset();
491497
mocks.collectWorkspaceStatusHealthFindings.mockReset();
492498
mocks.collectWorkspaceStatusHealthFindings.mockResolvedValue([]);
499+
mocks.collectDiskSpaceHealthFindings.mockReset();
500+
mocks.collectDiskSpaceHealthFindings.mockReturnValue([]);
493501
mocks.collectDevicePairingHealthFindings.mockReset();
494502
mocks.collectDevicePairingHealthFindings.mockResolvedValue([]);
495503
mocks.scanConfiguredChannelPluginBlockers.mockReset();
@@ -1211,6 +1219,7 @@ describe("doctor health contributions", () => {
12111219
expect(contributionIds).toContain("core/doctor/session-snapshots");
12121220
expect(contributionIds).toContain("core/doctor/plugin-registry");
12131221
expect(contributionIds).toContain("core/doctor/configured-plugin-installs");
1222+
expect(contributionIds).toContain("core/doctor/disk-space");
12141223
expect(contributionIds).toContain("core/doctor/device-pairing");
12151224
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
12161225
expect(contributionIds).toContain("core/doctor/tool-result-cap");
@@ -1403,6 +1412,47 @@ describe("doctor health contributions", () => {
14031412
expect(findings).toEqual([]);
14041413
});
14051414

1415+
it("keeps disk space opt-in for default lint selection", async () => {
1416+
const contributionChecks = await resolveDoctorContributionHealthChecks();
1417+
const diskSpaceCheck = contributionChecks.find(
1418+
(check) => check.id === "core/doctor/disk-space",
1419+
);
1420+
expect(diskSpaceCheck).toMatchObject({ defaultEnabled: false });
1421+
expect(diskSpaceCheck).toBeDefined();
1422+
1423+
const ctx = {
1424+
cfg: {},
1425+
mode: "lint",
1426+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
1427+
} as const;
1428+
const checks = [diskSpaceCheck!];
1429+
1430+
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
1431+
checksRun: 0,
1432+
checksSkipped: 1,
1433+
});
1434+
expect(mocks.collectDiskSpaceHealthFindings).not.toHaveBeenCalled();
1435+
1436+
mocks.collectDiskSpaceHealthFindings.mockReturnValueOnce([
1437+
{
1438+
checkId: "core/doctor/disk-space",
1439+
severity: "warning",
1440+
message: "Low disk space: 300 MB free on the partition containing ~/.openclaw.",
1441+
path: "/home/test/.openclaw",
1442+
requirement: "low-free-space",
1443+
},
1444+
]);
1445+
1446+
await expect(
1447+
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/disk-space"] }),
1448+
).resolves.toMatchObject({
1449+
checksRun: 1,
1450+
checksSkipped: 0,
1451+
findings: [expect.objectContaining({ checkId: "core/doctor/disk-space" })],
1452+
});
1453+
expect(mocks.collectDiskSpaceHealthFindings).toHaveBeenCalledWith(ctx.cfg);
1454+
});
1455+
14061456
it("keeps device pairing opt-in for default lint selection", async () => {
14071457
const contributionChecks = await resolveDoctorContributionHealthChecks();
14081458
const devicePairingCheck = contributionChecks.find(

src/flows/doctor-health-contributions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,16 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
15211521
createDoctorHealthContribution({
15221522
id: "doctor:disk-space",
15231523
label: "Disk space",
1524+
healthChecks: {
1525+
id: "core/doctor/disk-space",
1526+
description: "Low disk space around the OpenClaw state directory is a finding.",
1527+
defaultEnabled: false,
1528+
async detect(ctx) {
1529+
const { collectDiskSpaceHealthFindings } =
1530+
await import("../commands/doctor-disk-space.js");
1531+
return collectDiskSpaceHealthFindings(ctx.cfg);
1532+
},
1533+
},
15241534
run: runDiskSpaceHealth,
15251535
}),
15261536
createDoctorHealthContribution({

0 commit comments

Comments
 (0)