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

190 lines (189 loc) • 8.52 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/. /** * Append-only events stream — backed by state.db (#204, Phase 3). * * Every mutating CLI verb funnels through `appendEvent` so external * observers (sync, replication, audit, dashboards) can react to stash * changes. Events are stored in the `events` table in `state.db` * (SQLite, WAL mode) instead of a flat `events.jsonl` file. * * The helper is the only thing in akm that writes to the events table. It * accepts an injectable `dbPath` (via `EventsContext`) so tests can pin a * tmpdir without any global mutation. * * Format (each EventEnvelope): * { "schemaVersion": 1, "id": <number>, "ts": "<ISO>", * "eventType": "<verb>", "ref"?: "<asset-ref>", ... } * * - `id` is a monotonic SQLite AUTOINCREMENT rowid. Callers can persist it * as a durable cursor for `--since` resumption. The public API surfaces this * as the opaque `nextOffset` number. * - `ts` is ISO-8601 (UTC, millisecond precision). */ import fs from "node:fs"; import { openDatabase } from "../storage/database.js"; import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js"; import { rethrowIfTestIsolationError } from "./errors.js"; import { getStateDbPath, openStateDatabase, withStateDb } from "./state-db.js"; import { borrowScopedStateDb } from "./state-db-scope.js"; import { isReadOnlyFilesystemError } from "./system-error.js"; import { error } from "./warn.js"; /** * Resolve the state.db path from context: * 1. `ctx.dbPath` — explicit override (test seam) * 2. default — the canonical state.db path */ function resolveDbPath(ctx) { if (ctx?.dbPath) return ctx.dbPath; return getStateDbPath(); } function resolveNow(ctx) { return ctx?.now ?? Date.now; } /** * Append a single event. Best-effort: a write failure is logged once to * stderr but never propagates — observability must not break mutation. * * Events are written exclusively to the `events` table in `state.db`. * * I1: when `ctx.db` is provided (a pre-opened long-lived connection), the * function writes directly to that handle without opening or closing the DB. * This eliminates per-event open/migrate/close overhead for high-frequency * callers such as `akmImprove`. * * The same fast path is taken IMPLICITLY inside a `withStateDbScope` / * `withWorkflowRunsConnection` scope (`core/state-db-scope.ts`): the ambient * scoped handle for this event's resolved `dbPath` is borrowed, so a workflow * step's `workflow_unit_started` / `workflow_unit_finished` pair rides the same * connection its journal rows do instead of opening state.db twice per unit. * The scope owns that handle's lifetime; `appendEvent` never closes a borrowed * connection. */ export function appendEvent(input, ctx) { if (ctx?.readOnly) return; const now = resolveNow(ctx); const ts = new Date(now()).toISOString(); const row = { eventType: input.eventType, ts, ref: input.ref, metadata: input.metadata }; // One try covers EVERY path — including resolving the state.db path, which // reads the environment and throws where no data dir can be derived — so the // best-effort contract ("a write failure never propagates") holds no matter // which handle this event lands on. try { // Fast path: an explicitly supplied long-lived connection. Resolution is // skipped outright, which is what "`dbPath` is ignored when `db` is // provided" has to mean for a caller that already holds an open handle. if (ctx?.db) { insertEvent(ctx.db, row); return; } const dbPath = resolveDbPath(ctx); // The ambient scoped handle for this path, when a scope is open — borrowed // exactly like the explicit one, and never closed here. const borrowed = borrowScopedStateDb(dbPath); if (borrowed) { insertEvent(borrowed, row); return; } // Default path: open, insert, close. withStateDb((db) => insertEvent(db, row), { path: dbPath }); } catch (err) { // Never mask the bun-test isolation guard as a silent "events failed". rethrowIfTestIsolationError(err); // Read-only sandboxes can serve an existing index but cannot create the // maintenance lock or state DB used by best-effort usage tracking. That is // expected for a read verb; unrelated storage failures remain visible. if (isReadOnlyFilesystemError(err)) return; // Best-effort: events stream failures must not break the mutating verb. // Surface once to stderr so operators can diagnose. error(`akm: appendEvent failed: ${String(err)}`); } } /** * Read all events matching the filter. Returns a `nextOffset` that callers * can persist between processes for monotonic resumption. */ export function readEvents(options = {}, ctx) { if (ctx?.readOnlySnapshotUnavailable) return { events: [], nextOffset: 0 }; const dbPath = resolveDbPath(ctx); let db; let ownsDb = false; try { if (ctx?.db) { db = ctx.db; } else if (ctx?.readOnly) { if (!fs.existsSync(dbPath)) return { events: [], nextOffset: 0 }; db = openDatabase(dbPath, { readonly: true, create: false }); ownsDb = true; } else { db = openStateDatabase(dbPath); ownsDb = true; } } catch (err) { // Never mask the bun-test isolation guard as "no events". rethrowIfTestIsolationError(err); // DB does not exist yet or cannot be opened — return empty result. return { events: [], nextOffset: 0 }; } try { // D-38: a JS-side post-filter (the tag filters // below) runs AFTER the SQL read, so a SQL-level LIMIT applied before it // could drop rows the post-filter would have kept out anyway, silently // returning fewer than `limit` (or the wrong — oldest-in-the-SQL-window — // events). Only push `limit` into SQL (readStateEvents) when nothing // downstream can shrink the result further; otherwise read unbounded (the // pre-existing behavior) and apply `limit` ourselves, below, AFTER the // post-filter runs. const needsPostFilter = (options.excludeTags?.length ?? 0) > 0 || (options.includeTags?.length ?? 0) > 0 || options.runId !== undefined; const pushLimitToSql = options.limit !== undefined && !needsPostFilter; let rawEvents; let nextId; try { ({ events: rawEvents, nextId } = readStateEvents(db, { sinceId: options.sinceOffset, since: options.since, type: options.type, ref: options.ref, ...(pushLimitToSql ? { limit: options.limit } : {}), })); } catch (error) { if (ctx?.readOnly && error instanceof Error && /no such table:/i.test(error.message)) { return { events: [], nextOffset: options.sinceOffset ?? 0 }; } throw error; } const filtered = rawEvents.filter((envelope) => { if (options.runId !== undefined && envelope.metadata?.runId !== options.runId) return false; // Apply tag filters after the indexed state.db read. const tags = envelope.metadata?.tags ?? []; if (options.excludeTags?.some((t) => tags.includes(t))) return false; if (options.includeTags && !options.includeTags.every((t) => tags.includes(t))) return false; return true; }); // `filtered` is ascending by id; slicing off the end keeps the MOST // RECENT `limit` events post-filter, matching the SQL-pushdown path's // semantics exactly. `nextOffset` intentionally stays `nextId` — the // durable resume cursor tracks the underlying SQL read, not this // display-only truncation. const events = options.limit !== undefined && !pushLimitToSql ? filtered.slice(-options.limit) : filtered; return { events, nextOffset: nextId }; } finally { if (ownsDb) db.close(); } }