From 9d97e683d419d64fcfd5b63d0b32fa1879cbc25d Mon Sep 17 00:00:00 2001 From: alkor2000 <131229172+alkor2000@users.noreply.github.com> Date: Mon, 1 Jun 2026 05:09:36 +0800 Subject: [PATCH] feat(doctor): add disk space health check Add a Doctor health contribution that checks free space on the partition containing the active OpenClaw state directory. Doctor now warns below 500 MB and reports critical below 100 MB so disk pressure is visible before config writes, session transcripts, or log rotation start failing. The contribution reuses the shared `src/infra/disk-space.ts` probe, runs before state integrity, and is registered in the Doctor health conversion plan with focused coverage for thresholds, formatting, and note behavior. PR: #59196 Proof: `pnpm test src/commands/doctor-disk-space.test.ts src/flows/doctor-health-conversion-plan.test.ts`; `git diff --check origin/main...HEAD`; `git merge-tree --write-tree origin/main refs/remotes/pr/59196`; GitHub CI run `26720861380`; Real behavior proof run `26720996848`. Co-authored-by: alkor2000 <200923177@qq.com> --- src/commands/doctor-disk-space.test.ts | 169 +++++++++++++++++++++ src/commands/doctor-disk-space.ts | 109 +++++++++++++ src/flows/doctor-health-contributions.ts | 10 ++ src/flows/doctor-health-conversion-plan.ts | 6 + 4 files changed, 294 insertions(+) create mode 100644 src/commands/doctor-disk-space.test.ts create mode 100644 src/commands/doctor-disk-space.ts diff --git a/src/commands/doctor-disk-space.test.ts b/src/commands/doctor-disk-space.test.ts new file mode 100644 index 00000000000..1db15381a88 --- /dev/null +++ b/src/commands/doctor-disk-space.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildDiskSpaceWarnings, + formatBytes, + noteDiskSpace, +} from "./doctor-disk-space.js"; + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ + note: vi.fn(), +})); + +describe("formatBytes", () => { + it("formats zero bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + }); + + it("formats bytes below 1 KB", () => { + expect(formatBytes(512)).toBe("512 B"); + }); + + it("formats kilobytes", () => { + expect(formatBytes(2048)).toBe("2 KB"); + }); + + it("formats megabytes", () => { + expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB"); + }); + + it("floors megabytes to avoid crossing a threshold (99.6 MB -> 99 MB)", () => { + expect(formatBytes(Math.floor(99.6 * 1024 * 1024))).toBe("99 MB"); + }); + + it("formats gigabytes with one decimal", () => { + expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe("2.5 GB"); + }); + + it("returns unknown for negative values", () => { + expect(formatBytes(-1)).toBe("unknown"); + }); + + it("returns unknown for NaN", () => { + expect(formatBytes(Number.NaN)).toBe("unknown"); + }); + + it("returns unknown for Infinity", () => { + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("unknown"); + }); +}); + +describe("buildDiskSpaceWarnings", () => { + it("returns empty array when space is sufficient", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 10 * 1024 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toEqual([]); + }); + + it("returns warning lines when space is low (below 500 MB)", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 300 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain("Low disk space"); + expect(warnings[0]).toContain("300 MB"); + expect(warnings[0]).toContain("~/.openclaw"); + }); + + it("returns critical lines when space is very low (below 100 MB)", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 50 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain("CRITICAL"); + expect(warnings[0]).toContain("50 MB"); + }); + + it("returns critical at exactly 0 bytes", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 0, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain("CRITICAL"); + }); + + it("returns empty at exactly 500 MB (boundary)", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 500 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toEqual([]); + }); + + it("returns warning at 499 MB (just below boundary)", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 499 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain("Low disk space"); + }); + + it("returns critical at exactly 99 MB (just below critical)", () => { + const warnings = buildDiskSpaceWarnings({ + availableBytes: 99 * 1024 * 1024, + displayStateDir: "~/.openclaw", + }); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain("CRITICAL"); + }); +}); + +describe("noteDiskSpace", () => { + it("calls note when space is below warning threshold", async () => { + const { note: mockNote } = await import("../../packages/terminal-core/src/note.js"); + vi.mocked(mockNote).mockClear(); + + noteDiskSpace({ gateway: { mode: "local" } } as never, { + env: { HOME: "/home/test" }, + readDiskSpace: () => ({ availableBytes: 300 * 1024 * 1024 }), + }); + + expect(mockNote).toHaveBeenCalledOnce(); + const [message, title] = vi.mocked(mockNote).mock.calls[0]; + expect(title).toBe("Disk space"); + expect(message).toContain("Low disk space"); + }); + + it("calls note with CRITICAL when space is very low", async () => { + const { note: mockNote } = await import("../../packages/terminal-core/src/note.js"); + vi.mocked(mockNote).mockClear(); + + noteDiskSpace({ gateway: { mode: "local" } } as never, { + env: { HOME: "/home/test" }, + readDiskSpace: () => ({ availableBytes: 50 * 1024 * 1024 }), + }); + + expect(mockNote).toHaveBeenCalledOnce(); + const [message] = vi.mocked(mockNote).mock.calls[0]; + expect(message).toContain("CRITICAL"); + }); + + it("does not call note when space is sufficient", async () => { + const { note: mockNote } = await import("../../packages/terminal-core/src/note.js"); + vi.mocked(mockNote).mockClear(); + + noteDiskSpace({ gateway: { mode: "local" } } as never, { + env: { HOME: "/home/test" }, + readDiskSpace: () => ({ availableBytes: 10 * 1024 * 1024 * 1024 }), + }); + + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("does not call note when disk space cannot be read", async () => { + const { note: mockNote } = await import("../../packages/terminal-core/src/note.js"); + vi.mocked(mockNote).mockClear(); + + noteDiskSpace({ gateway: { mode: "local" } } as never, { + env: { HOME: "/home/test" }, + readDiskSpace: () => null, + }); + + expect(mockNote).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-disk-space.ts b/src/commands/doctor-disk-space.ts new file mode 100644 index 00000000000..071dc3254de --- /dev/null +++ b/src/commands/doctor-disk-space.ts @@ -0,0 +1,109 @@ +import os from "node:os"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { tryReadDiskSpace } from "../infra/disk-space.js"; +import { note } from "../../packages/terminal-core/src/note.js"; +import { shortenHomePath } from "../utils.js"; + +// 100 MB — below this, config writes and session transcripts are likely to +// fail silently, causing data loss. +const CRITICAL_BYTES = 100 * 1024 * 1024; + +// 500 MB — enough headroom for normal operation but worth a heads-up so +// operators can free space before it becomes critical. +const WARNING_BYTES = 500 * 1024 * 1024; + +/** + * Format a byte count into a human-readable string (B / KB / MB / GB). + * Uses Math.floor for MB/KB values to avoid rounding up past a decision + * threshold (e.g. 99.6 MB should display as "99 MB", not "100 MB"). + * Exported for testing. + */ +export function formatBytes(bytes: number): string { + if (bytes < 0 || !Number.isFinite(bytes)) { + return "unknown"; + } + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + if (bytes >= 1024 * 1024) { + return `${Math.floor(bytes / (1024 * 1024))} MB`; + } + if (bytes >= 1024) { + return `${Math.floor(bytes / 1024)} KB`; + } + return `${bytes} B`; +} + +/** + * Build warning lines based on available disk space. + * Pure function — exported for testing without FS side effects. + */ +export function buildDiskSpaceWarnings(params: { + availableBytes: number; + displayStateDir: string; +}): string[] { + const { availableBytes, displayStateDir } = params; + const displayFreeSpace = formatBytes(availableBytes); + const warnings: string[] = []; + + if (availableBytes < CRITICAL_BYTES) { + warnings.push( + `- CRITICAL: only ${displayFreeSpace} free on the partition containing ${displayStateDir}.`, + ); + warnings.push("- Config writes, session transcripts, and log rotation may fail silently."); + warnings.push("- Free up disk space immediately to avoid data loss."); + } else if (availableBytes < WARNING_BYTES) { + warnings.push( + `- Low disk space: ${displayFreeSpace} free on the partition containing ${displayStateDir}.`, + ); + warnings.push("- Consider freeing space to prevent future config/session write failures."); + } + + return warnings; +} + +/** + * Doctor health contribution: check free disk space on the partition that + * holds the state directory and warn when it drops below safe thresholds. + * + * This catches a common operational failure mode where OpenClaw silently + * fails to write config, sessions, or logs because the disk is full. + * + * Disk-space probing (statfs + nearest-existing-ancestor resolution) is + * delegated to the shared src/infra/disk-space.ts helper so this Doctor + * check and the install/update diagnostics stay on one implementation. + * The two-tier warning/critical thresholds and Doctor-facing formatting + * are specific to this health contribution. + */ +export function noteDiskSpace( + _cfg: OpenClawConfig, // reserved for API consistency with other Doctor contributions + deps?: { + env?: NodeJS.ProcessEnv; + readDiskSpace?: (targetPath: string) => { availableBytes: number } | null; + }, +): void { + const env = deps?.env ?? process.env; + const homedir = () => resolveRequiredHomeDir(env, os.homedir); + const stateDir = resolveStateDir(env, homedir); + + const readDiskSpace = deps?.readDiskSpace ?? tryReadDiskSpace; + const snapshot = readDiskSpace(stateDir); + // If we cannot determine free space (no existing ancestor, unsupported FS, + // or permission error), skip silently — other contributions already + // handle missing directories. + if (!snapshot) { + return; + } + + const displayStateDir = shortenHomePath(stateDir); + const warnings = buildDiskSpaceWarnings({ + availableBytes: snapshot.availableBytes, + displayStateDir, + }); + + if (warnings.length > 0) { + note(warnings.join("\n"), "Disk space"); + } +} diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 9f7760f07a4..0bcf507ec9d 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -446,6 +446,11 @@ async function runReleaseConfiguredPluginInstallsHealth( }; } +async function runDiskSpaceHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteDiskSpace } = await import("../commands/doctor-disk-space.js"); + noteDiskSpace(ctx.cfg); +} + async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise { const { noteStateIntegrity } = await loadDoctorStateIntegrityModule(); await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath); @@ -1064,6 +1069,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { label: "Plugin registry", run: runPluginRegistryHealth, }), + createDoctorHealthContribution({ + id: "doctor:disk-space", + label: "Disk space", + run: runDiskSpaceHealth, + }), createDoctorHealthContribution({ id: "doctor:state-integrity", label: "State integrity", diff --git a/src/flows/doctor-health-conversion-plan.ts b/src/flows/doctor-health-conversion-plan.ts index ffa4fc0a621..63a2161309a 100644 --- a/src/flows/doctor-health-conversion-plan.ts +++ b/src/flows/doctor-health-conversion-plan.ts @@ -81,6 +81,12 @@ export const doctorHealthConversionRules = [ target: ["core/doctor/plugin-registry"], rule: "Detect stale plugin registry state and let repair return the next config.", }, + { + contributionId: "doctor:disk-space", + conversion: "terminal-side-effect", + target: ["doctor-run/disk-space"], + rule: "Currently emits low/critical free-space warnings via note(); convert to a path-scoped read-only finding (no repair) when the disk-space check gains a structured detector.", + }, { contributionId: "doctor:state-integrity", conversion: "repair-backed-detect",