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",