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
JavaScript
// 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();
}
}