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

245 lines (243 loc) • 11.1 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/. /** * logs.db — Dedicated SQLite database for task/run log lines (#579). * * Replaces grep-the-flat-file consumption of `<cacheDir>/tasks/logs/<id>/<ts>.log` * with structured, indexed rows: `{ts, task_id, run_id, stream, level, line}`. * The strategic direction (stop scattering data across files/folders) means * every NEW log consumer queries this database; the per-run text file written * by the task runner is retained only as a transitional tail for humans — * per the #579 logs audit of every producer. * * ## Why a separate database from state.db * * Log lines are high-volume, append-only, and freely purgeable; state.db rows * (events, proposals, task_history) are durable records. Separating them keeps * state.db small and lets log retention be aggressive without touching durable * state. Callers that need to correlate a task_history row with its log lines do * an application-side join on the {@link buildTaskRunId} key (e.g. `health` via * {@link getLoggedRunIds}) — no SQLite ATTACH, so the split survives a future * provider change. * * ## run_id * * state.db's `task_history` identifies a run by the unique pair * `(task_id, started_at)` (see migration 002 in state-db.ts). logs.db encodes * that pair as a single string — {@link buildTaskRunId} — so log rows can be * joined back to their history row: * * l.run_id = th.task_id || '@' || th.started_at * * ## Schema evolution * * Same migration-safety contract as state.db: append-only `MIGRATIONS` applied * through the shared runner in src/storage/sqlite-migrations.ts. * * @module logs-db */ import path from "node:path"; import { openManagedDatabase } from "../storage/managed-db.js"; import { assertMigrationRegistry, runMigrations as runSqliteMigrations, } from "../storage/sqlite-migrations.js"; import { getDataDir } from "./paths.js"; // ── Path helper ────────────────────────────────────────────────────────────── /** * Default path: `<dataDir>/logs.db` — alongside state.db so cooperating * processes sharing a data root automatically share the same logs database * (same `AKM_DATA_DIR` / XDG env-isolation as {@link getStateDbPath}). */ export function getLogsDbPath() { return path.join(getDataDir(), "logs.db"); } // ── Database open ──────────────────────────────────────────────────────────── /** * Open (and initialise / migrate) the logs database. * * @param dbPath - Override the database file path (tests pass a tmpdir path). * * PRAGMA rationale: * * journal_mode = WAL * Readers never block writers and vice-versa; crashes are safe (the WAL is * replayed on next open). Required because the task runner writes log rows * while `akm health` may be reading them. * * busy_timeout = 30000 * Log writes happen at the end of scheduled task runs, which can pile up * (cron fan-out). 30 s of retry absorbs a slow concurrent writer instead of * surfacing SQLITE_BUSY and dropping log lines. */ export function openLogsDatabase(dbPath) { const resolvedPath = dbPath ?? getLogsDbPath(); // foreignKeys:false preserves this opener's historical behaviour — logs.db // has never enforced foreign keys. return openManagedDatabase({ path: resolvedPath, pragmas: { dataDir: path.dirname(resolvedPath), foreignKeys: false }, init: runMigrations, }); } // ── Migrations ─────────────────────────────────────────────────────────────── /** * All migrations in application order. APPEND only — never insert in the * middle or reorder. Same contract as state.db's MIGRATIONS array. */ const MIGRATIONS = [ // ── Migration 001 — task_logs ─────────────────────────────────────────────── // // One row per log line emitted by a task run. // // Indexed (query) columns: // ts TEXT — ISO-8601 UTC; range queries ("logs in the last hour"). // task_id TEXT — task identifier; per-task log views. // run_id TEXT — buildTaskRunId(task_id, started_at); per-run log views // and the join key back to state.db task_history. // // Non-indexed columns: // stream TEXT — 'stdout' | 'stderr'; which pipe the line came from. // level TEXT — 'info' | 'warn' | 'error'; runner-assigned severity // ('info' for captured stdout, 'error' for stderr and // failure diagnostics). // line TEXT — the log line itself (no trailing newline). // // ADD COLUMN extension points (future migrations): // ALTER TABLE task_logs ADD COLUMN seq INTEGER DEFAULT NULL; // ALTER TABLE task_logs ADD COLUMN source TEXT DEFAULT NULL; // // TTL: rows where ts < NOW() - retention can be deleted by purgeOldTaskLogs(). // No automatic deletion occurs here. { id: "001-task-logs", up: ` CREATE TABLE IF NOT EXISTS task_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, task_id TEXT NOT NULL, run_id TEXT NOT NULL, stream TEXT NOT NULL DEFAULT 'stdout', level TEXT NOT NULL DEFAULT 'info', line TEXT NOT NULL ); -- Query patterns: -- SELECT … WHERE ts >= ? AND ts <= ? → idx_task_logs_ts (purge, windows) -- SELECT … WHERE task_id = ? → idx_task_logs_task_id -- SELECT … WHERE run_id = ? → idx_task_logs_run_id (per-run tail) CREATE INDEX IF NOT EXISTS idx_task_logs_ts ON task_logs(ts); CREATE INDEX IF NOT EXISTS idx_task_logs_task_id ON task_logs(task_id); CREATE INDEX IF NOT EXISTS idx_task_logs_run_id ON task_logs(run_id); `, }, ]; assertMigrationRegistry(MIGRATIONS); /** * Apply every pending migration. Called automatically by * {@link openLogsDatabase}; exported for the same test seams state-db exposes. */ export function runMigrations(db) { runSqliteMigrations(db, MIGRATIONS); } // ── run_id ─────────────────────────────────────────────────────────────────── /** * Encode a task run's identity — the unique `(task_id, started_at)` pair from * state.db `task_history` — as a single run_id string. * * The format MUST stay in sync with the application-side join key that callers * build from a `task_history` row's `task_id` and `started_at`. */ export function buildTaskRunId(taskId, startedAtIso) { return `${taskId}@${startedAtIso}`; } /** * Insert a batch of log lines for one task run in a single transaction. * Returns the number of rows inserted. Lines are stored in array order * (ascending rowid), so reading back `ORDER BY id` reproduces emission order. * * Errors propagate — the task runner wraps this in its own best-effort * handling (mirroring `appendHistory`) so an unwritable logs.db never fails * a task run. */ export function insertTaskLogLines(db, input) { if (input.lines.length === 0) return 0; const stmt = db.prepare(`INSERT INTO task_logs (ts, task_id, run_id, stream, level, line) VALUES (?, ?, ?, ?, ?, ?)`); db.transaction(() => { for (const entry of input.lines) { stmt.run(input.ts, input.taskId, input.runId, entry.stream ?? "stdout", entry.level ?? "info", entry.line); } })(); return input.lines.length; } /** * Read log lines matching the filter, in emission order (ascending id). * * Connection-lifetime rule (WS5): `.all()` materializes a plain array before * returning. */ export function queryTaskLogs(db, options = {}) { const conditions = []; const params = []; if (options.taskId) { conditions.push("task_id = ?"); params.push(options.taskId); } if (options.runId) { conditions.push("run_id = ?"); params.push(options.runId); } if (options.stream) { conditions.push("stream = ?"); params.push(options.stream); } if (options.since) { conditions.push("ts >= ?"); params.push(options.since); } if (options.until) { conditions.push("ts < ?"); params.push(options.until); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const limit = options.limit !== undefined && options.limit >= 0 ? ` LIMIT ${Math.floor(options.limit)}` : ""; return db .prepare(`SELECT id, ts, task_id, run_id, stream, level, line FROM task_logs ${where} ORDER BY id ASC${limit}`) .all(...params); } /** * Bulk membership check: which of `runIds` have at least one log row? * Used by `akm health` to compute the log-backing rate from the database * instead of `fs.existsSync` over scattered files. Chunked to stay under * SQLite's bound-parameter ceiling. */ export function getLoggedRunIds(db, runIds) { const out = new Set(); if (runIds.length === 0) return out; const CHUNK = 500; for (let i = 0; i < runIds.length; i += CHUNK) { const chunk = runIds.slice(i, i + CHUNK); const placeholders = chunk.map(() => "?").join(","); const rows = db .prepare(`SELECT DISTINCT run_id FROM task_logs WHERE run_id IN (${placeholders})`) .all(...chunk); for (const row of rows) out.add(row.run_id); } return out; } // ── Retention ──────────────────────────────────────────────────────────────── /** * Delete task_logs rows older than `retentionDays` (default: 90). Mirrors * `purgeOldEvents` / `purgeOldImproveRuns` in state-db.ts — same default, same * return shape (rows deleted), same disabled-when-non-positive semantics. * Wired into the improve maintenance pass alongside the state.db purges. */ export function purgeOldTaskLogs(db, retentionDays = 90) { if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0; const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); const result = db.prepare("DELETE FROM task_logs WHERE ts < ?").run(cutoff); const changes = result.changes ?? 0; return typeof changes === "bigint" ? Number(changes) : changes; }