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

134 lines (133 loc) • 6.37 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/. /** * state.db integrity + reclaimable-space probes (R0), and the VACUUM pass * state.db and index.db share. * * `akm health`'s `state-db-integrity` check (src/commands/health/checks.ts) * is a pure projection like every other check, so the actual IO lives here: * a read-only `PRAGMA integrity_check` and a read-only freelist/page-count read. * Both open their own short-lived read-only connection via the plain * {@link openDatabase} opener rather than `openStateDatabase` * (src/core/state-db.ts), since a corrupt database must not need a clean * migration-ledger read, let alone a migration, just to report itself as * corrupt. * * {@link vacuumIfReclaimable} is the VACUUM step: given an already-open * read-write connection (VACUUM cannot run inside a transaction, and a * read-only handle cannot run it at all) and a freelist reading, it VACUUMs * when asked to or when the freelist ratio crosses * {@link STATE_DB_FREELIST_WARN_RATIO}, and never throws — a locked/busy * database is reported, not raised. improve's post-purge pass runs it on * state.db, and `akm index` on index.db. * * @module storage/state-db-integrity */ import { appendEvent } from "../core/events.js"; import { openDatabase } from "./database.js"; import { applyReadonlyPragmas } from "./sqlite-pragmas.js"; /** How many corruption errors `PRAGMA integrity_check` collects before it stops scanning and returns. */ const INTEGRITY_CHECK_ERROR_LIMIT = 10; /** * Above this fraction of free pages, `state-db-integrity` warns, and * {@link vacuumIfReclaimable} compacts state.db (after improve's retention * purge) or index.db (at the end of `akm index`). */ export const STATE_DB_FREELIST_WARN_RATIO = 0.5; /** Event appended by {@link vacuumIfReclaimable} after a successful VACUUM of state.db. */ export const STATE_DB_VACUUMED_EVENT = "state_db_vacuumed"; /** Event appended by {@link vacuumIfReclaimable} after a successful VACUUM of index.db. */ export const INDEX_DB_VACUUMED_EVENT = "index_db_vacuumed"; function firstColumn(row) { return row === undefined ? undefined : Object.values(row)[0]; } function openReadonlyStateDb(dbPath) { const db = openDatabase(dbPath, { readonly: true, create: false }); applyReadonlyPragmas(db); return db; } /** * Run `PRAGMA integrity_check(N)` against `dbPath` read-only. Unlike * `quick_check`, it also verifies every index against its table ("row N * missing from index", "wrong # of entries in index"): the damage a WAL * deleted under a live connection leaves behind, which `quick_check` reports * as `ok`. Sub-second on a healthy state.db of a few hundred MB; on a corrupt * one, `N` bounds how many errors SQLite collects before it stops scanning, * which keeps the check's runtime bounded even against a badly corrupt file. */ export function runStateDbIntegrityCheck(dbPath) { let db; try { db = openReadonlyStateDb(dbPath); const rows = db.prepare(`PRAGMA integrity_check(${INTEGRITY_CHECK_ERROR_LIMIT})`).all(); const lines = rows.map((row) => String(firstColumn(row))); const ok = lines.length === 1 && lines[0] === "ok"; return { ok, lines }; } catch (err) { return { ok: false, lines: [], error: err instanceof Error ? err.message : String(err) }; } finally { db?.close(); } } /** * Read `PRAGMA freelist_count` / `PRAGMA page_count` off an already-open * connection. Shared by {@link getStateDbFreelistInfo} (which opens its own * read-only handle) and the post-purge VACUUM call site, which must read the * freelist off the same read-write connection the purge just used rather * than open a second one. */ export function readFreelistInfo(db) { const freelistCount = Number(firstColumn(db.prepare("PRAGMA freelist_count").get()) ?? 0); const pageCount = Number(firstColumn(db.prepare("PRAGMA page_count").get()) ?? 0); return { freelistCount, pageCount, ratio: pageCount > 0 ? freelistCount / pageCount : 0 }; } /** Read `PRAGMA freelist_count` / `PRAGMA page_count` — how much of state.db is reclaimable by VACUUM. */ export function getStateDbFreelistInfo(dbPath) { let db; try { db = openReadonlyStateDb(dbPath); return readFreelistInfo(db); } catch (err) { return { freelistCount: 0, pageCount: 0, ratio: 0, error: err instanceof Error ? err.message : String(err) }; } finally { db?.close(); } } /** * VACUUM `db` when `vacuum.force` is set or `freelist.ratio` exceeds * {@link STATE_DB_FREELIST_WARN_RATIO}, appending `vacuum.eventType` with * the pages before/after. Runs on the caller's read-write connection — for * state.db right after the retention purge, for index.db at the end of an * index run. Never throws: a locked/busy database (another writer holds the * file right now) is reported via `reason: "busy"` rather than raised, since * this is opportunistic maintenance and must not fail the pass it follows. * * The event is appended via `appendEvent` (not a direct `insertEvent` on * `db`) so it honors the caller's `EventsContext` — `readOnly` suppresses * the write and an injected `now` is used for `ts` — the same as every * other event `runRetentionPurgePass` appends in this callback. */ export function vacuumIfReclaimable(db, freelist, vacuum, eventsCtx) { if (!vacuum.force && freelist.ratio <= STATE_DB_FREELIST_WARN_RATIO) { return { ran: false, reason: "below-threshold", pagesBefore: freelist.pageCount }; } try { db.exec("VACUUM"); } catch (err) { const message = err instanceof Error ? err.message : String(err); const busy = /busy|locked/i.test(message); return { ran: false, reason: busy ? "busy" : "error", pagesBefore: freelist.pageCount, error: message }; } const pagesAfter = Number(firstColumn(db.prepare("PRAGMA page_count").get()) ?? 0); appendEvent({ eventType: vacuum.eventType, metadata: { pagesBefore: freelist.pageCount, pagesAfter, freelistRatioBefore: freelist.ratio }, }, eventsCtx); return { ran: true, pagesBefore: freelist.pageCount, pagesAfter }; }