UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

195 lines (194 loc) • 8.18 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * `data-dir-usage` advisory for `akm health` (#896). * * A real environment's `$XDG_DATA_HOME/akm` grew to 74 GB with none of the * ~20 health checks saying a word about disk. 70 of the 74 GB turned out to * be `backups/` (unpruned migration snapshots, see #897); the live working * set (state.db + index.db + logs.db) was ~4.2 GB. Naming the largest * top-level contributor and its share of the total is most of the value — * `backups/ is 70G (94% of data dir)` is self-diagnosing where "akm health * says nothing" is not. * * Best-effort and read-only: a plain recursive `fs` stat walk over the data * dir, no `du` shell-out. Silent (returns `undefined`) whenever nothing * looks wrong, matching the stash-exposure/type-directory-check house * pattern — this is not a "always show a pass line" check. */ import fs from "node:fs"; import path from "node:path"; /** * Warn when the data dir's total size is more than this many times the * combined size of the three live databases (state.db + index.db + * logs.db). Chosen so a healthy install (backups roughly comparable to the * live working set) stays quiet, while an order-of-magnitude blowup like * the 74 GB/4.2 GB (~17x) incident trips it. */ const DATA_DIR_BLOAT_RATIO_THRESHOLD = 3; /** * Warn when a single top-level subdirectory accounts for more than this * percentage of the data dir's total size — the "one thing ate the disk" * signal (94% for `backups/` in the incident). */ const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50; /** * Cap on the number of filesystem entries the recursive size walk will * `stat`. A 70 GB tree of a few thousand backup copies is cheap to walk * (stat-only), but a data dir polluted with hundreds of thousands of small * files (task logs, npm logs) must not make `akm health` slow. Past this * cap the walk stops descending further and the advisory says its size * figures are a lower bound. */ export const MAX_WALK_ENTRIES = 100_000; /** * Below this the data dir is not worth an opinion. The advisory exists for * disk blowups (74 GB in the incident); on a small directory a ratio is * arithmetic noise, and akm's own housekeeping can dominate it outright. */ const MIN_TOTAL_BYTES_TO_REPORT = 1_000_000_000; const LIVE_DB_FILES = ["state.db", "index.db", "logs.db"]; /** * SQLite writes `-wal` and `-shm` beside each database. They are part of the * live working set, not overhead sitting next to it, so they must count as * live: on a fresh install the WAL is most of the data dir, and leaving it * out of the denominator made an empty install report a ~126x ratio. */ function liveDbBytesFor(name, sizes) { return ((sizes.get(name)?.bytes ?? 0) + (sizes.get(`${name}-wal`)?.bytes ?? 0) + (sizes.get(`${name}-shm`)?.bytes ?? 0)); } /** * Recursively sum file sizes (and count files) under `root` (stat-only, * symlinks not followed so a cyclic or huge-target symlink can't blow up the * walk). `budget` is a shared mutable counter across the whole tree so the * entry cap applies to the walk as a whole, not per-branch — callers * typically pass {@link MAX_WALK_ENTRIES}, sized for this module's own data * dir walk, but a smaller/larger budget is fine for a different tree. * Shared with the `archive-usage` advisory (N2) — the same "don't let a * pathological tree hang a health check" concern applies to both. * * `onFile`, when given, is called once per leaf file (path, bytes) as the * walk visits it — `archive-usage` uses this to classify each file's git * state without a second, separate walk of the same tree. */ export function sizeOfPath(root, budget, onFile) { let stat; try { stat = fs.lstatSync(root); } catch { return { bytes: 0, files: 0, truncated: false }; } if (stat.isSymbolicLink()) return { bytes: 0, files: 0, truncated: false }; if (!stat.isDirectory()) { onFile?.(root, stat.size); return { bytes: stat.size, files: 1, truncated: false }; } let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return { bytes: 0, files: 0, truncated: false }; } let bytes = 0; let files = 0; let truncated = false; for (const entry of entries) { if (budget.remaining <= 0) { truncated = true; break; } budget.remaining--; const sub = sizeOfPath(path.join(root, entry.name), budget, onFile); bytes += sub.bytes; files += sub.files; if (sub.truncated) truncated = true; } return { bytes, files, truncated }; } /** `1610612736` -> `"1.5G"`. Values under 10 in a unit keep one decimal; 10+ round to an integer. */ function formatBytes(bytes) { const units = ["B", "K", "M", "G", "T"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } const rendered = unit === 0 ? String(Math.round(value)) : value < 10 ? value.toFixed(1) : String(Math.round(value)); return `${rendered}${units[unit]}`; } /** * Build the `data-dir-usage` advisory, or `undefined` when the data dir is * missing/empty/unreadable or its size looks unremarkable (neither * threshold trips). `dataDir` is the caller-resolved `getDataDir()` path — * this module never resolves paths or reads env itself. */ export function collectDataDirUsageAdvisory(dataDir) { let topEntries; try { topEntries = fs.readdirSync(dataDir, { withFileTypes: true }); } catch { return undefined; // no data dir yet — nothing to report. } const budget = { remaining: MAX_WALK_ENTRIES }; let totalBytes = 0; let truncated = false; const sizes = new Map(); for (const entry of topEntries) { const size = sizeOfPath(path.join(dataDir, entry.name), budget); totalBytes += size.bytes; if (size.truncated) truncated = true; sizes.set(entry.name, { bytes: size.bytes, isDirectory: entry.isDirectory() }); } if (totalBytes === 0) return undefined; const subdirs = [...sizes] .filter(([, size]) => size.isDirectory) .map(([name, size]) => ({ name, bytes: size.bytes, percent: (size.bytes / totalBytes) * 100 })) .sort((a, b) => b.bytes - a.bytes); const largest = subdirs[0]; const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, liveDbBytesFor(f, sizes)])); const liveDbBytes = Object.values(liveDbBreakdown).reduce((a, b) => a + b, 0); const ratio = liveDbBytes > 0 ? totalBytes / liveDbBytes : undefined; if (totalBytes < MIN_TOTAL_BYTES_TO_REPORT) return undefined; const bloatWarn = ratio !== undefined && ratio > DATA_DIR_BLOAT_RATIO_THRESHOLD; const dominantWarn = largest !== undefined && largest.percent > DOMINANT_SUBDIR_PERCENT_THRESHOLD; if (!bloatWarn && !dominantWarn) return undefined; const parts = [`data dir is ${formatBytes(totalBytes)} at ${dataDir}`]; if (largest) { parts.push(`${largest.name}/ is ${formatBytes(largest.bytes)} (${Math.round(largest.percent)}% of data dir)`); } if (ratio !== undefined) { parts.push(`live databases (${LIVE_DB_FILES.join("+")}) total ${formatBytes(liveDbBytes)}, ~${ratio.toFixed(1)}x smaller`); } if (truncated) { parts.push(`size figures are a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries`); } const message = `${parts.join("; ")}.`; return { name: "data-dir-usage", kind: "deterministic", status: "warn", confidence: "medium", message, evidence: { dataDir, totalBytes, liveDbBytes, liveDbBreakdown, largestSubdir: largest, ratio, walkBounded: truncated, maxWalkEntries: MAX_WALK_ENTRIES, }, }; }