feat: add SQLite-backed activity event logging layer (#1528)

* feat: add SQLite-backed activity event logging layer

Implements a structured diagnostic event trail for the orchestrator.
When unexpected behavior occurs (stuck sessions, silent CI failures,
missed PR transitions), operators can now reconstruct timelines with
`ao events` rather than guessing from logs.

Key design decisions driven by Codex review:
- FTS5 external-content table uses INSERT/DELETE triggers so search
  actually works without manual rebuild
- ts_epoch (epoch ms) used for all time comparisons to avoid text vs
  SQLite datetime() ambiguity near cutoff
- PRAGMA busy_timeout=3000 handles WAL lock contention across
  CLI/lifecycle/web processes
- user_version schema versioning for future migrations
- EventType renamed to ActivityEventKind to avoid collision with
  existing types.ts export
- Event names match existing vocabulary (ci.failing, review.pending)
- ActivityStateCache (Map) tracks previous activity state so
  lifecycle-manager can emit activity.transition diffs
- session.spawn_failed captures failed spawns via wrapper try/catch
- better-sqlite3 in optionalDependencies: AO keeps working if native
  build fails; getDb() returns null and writes become no-ops

New files:
- packages/core/src/events-db.ts — lazy DB init, WAL, schema+triggers
- packages/core/src/activity-events.ts — write API, sanitizer
- packages/core/src/query-activity-events.ts — query + FTS + stats
- packages/cli/src/commands/events.ts — `ao events list/search/stats`

Wired into:
- lifecycle-manager.ts: lifecycle.transition + activity.transition
- session-manager.ts: session.spawned, session.spawn_failed, session.killed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(activity-events): address PR review comments

- redactValue: remove value-string regex check that silently erased
  error messages mentioning auth terms (key-based redaction is sufficient)
- sanitizeData: reject payloads >16KB instead of slicing (sliced JSON
  is malformed and corrupts JSON output)
- lifecycle-manager: prune activityStateCache alongside states in the
  per-poll stale-entry cleanup loop (prevents unbounded growth)
- events CLI: warn on unrecognised --since duration format instead of
  silently applying no time filter
- searchActivityEvents: accept optional projectId and push it into SQL
  WHERE clause instead of post-LIMIT in-memory filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(activity-events): review pass fixes

- formatRow: pad level string before chalk-wrapping so ANSI codes
  don't corrupt column alignment in ao events output
- events-db: add PRAGMA synchronous=NORMAL (WAL+NORMAL is standard
  recommendation; avoids per-write fsync in the poll hot path)
- events-db: emit console.warn when _dbFailed is set so operators know
  events are being dropped instead of failing silently forever
- activity-events: remove dead redactValue wrapper (was a no-op after
  the previous fix; call site now directly assigns the value)
- activity-events: remove unused session.cleanup from ActivityEventKind
  (no call site emitted it; dead API surface)
- query-activity-events: add optional limit param to searchActivityEvents
  (default 100, max 1000, parameterized); add --limit flag to ao events search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(activity-events): widen source/kind to allow plugin-defined values

ActivityEventInput.source and .kind accept ActivityEventSource|string
and ActivityEventKind|string respectively, so new event sources (e.g.
scm-github, notifier-slack) don't require editing core types.

The named union members are preserved for IDE autocomplete on known values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(events): level column alignment, filter kind widening, negative limit guard

- events.ts: padEnd(5) → padEnd(9) so level column aligns with the 9-char LEVEL header
- query-activity-events.ts: ActivityEventFilter.kind widened to ActivityEventKind|string
  for consistency with ActivityEventInput.kind (plugin-defined kinds can now be queried)
- query-activity-events.ts: negative/NaN limit values sanitized with Number.isFinite +
  Math.max(1,...) to prevent SQLite LIMIT -N returning the full table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(events): FTS alias, credential URL sanitization, periodic retention

- query-activity-events.ts: use full table name in MATCH (activity_events_fts
  MATCH ?) instead of alias to avoid 'no such column: fts' in some FTS5 builds
- activity-events.ts: redact https://token@host URL credentials in string values;
  add hourly retention sweep so long-lived processes don't grow DB indefinitely
- events-fts-integration.test.ts: real SQLite integration test for FTS5 search,
  projectId filter, and epoch-based time filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): remove inline import() type annotations in integration test

ESLint @typescript-eslint/consistent-type-imports forbids import() in type
positions — replaced with any to keep the test working without the type imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(events): parse data to structured JSON in --json output; add test proving value sanitizer correctness

- ao events list/search --json now parses ActivityEvent.data from JSON
  string back to a structured object, making it jq-friendly without
  requiring double-parsing by callers (P1 finding from PR review)
- Add test "preserves error messages that mention sensitive words in
  values" to refute Greptile's false-positive P1 finding: SENSITIVE_KEY_RE
  only matches key names, not string values; "token expired" and
  "authorization header missing" values are preserved correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(events): BM25 relevance ordering for FTS search; add versioned JSON envelope

Search now orders by FTS5 rank (BM25) instead of ts_epoch, returning most relevant
events first. --json output now wraps events in { version, query, meta, events }
for stable CLI contracts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(events): quote FTS tokens to prevent false negatives on operator-like terms

Searching for words like "OR", "AND", or "NOT" would produce empty results
because SQLite FTS5 treated them as operators after token joining. Wrapping
each token in double quotes forces literal phrase matching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update FTS assertion for quoted tokens; raise timeout on slow audit test

query-activity-events test expected the old unquoted join format; update to
match the quoted form added in the previous commit. The agent-report audit
trail test occasionally exceeds the 5 s default on CI runners; raise to 15 s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(qa): ISSUE-001 - type events stats entries

* test(events): cover session and lifecycle event call sites

* test(core): wait for lifecycle branch adoption

* chore: add activity events changeset

* fix activity event review feedback

* batch prune old activity events

* record activity event when spawn starts

* Fix activity event FTS rebuild and sanitization guard

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-05-01 21:13:20 +05:30 committed by GitHub
parent 9ca3c1fcd7
commit 2306078761
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1840 additions and 7 deletions

View File

@ -0,0 +1,6 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-cli": patch
---
Add SQLite-backed activity event logging for session and lifecycle diagnostics, plus `ao events` commands for listing, searching, and inspecting event log stats.

View File

@ -0,0 +1,212 @@
import chalk from "chalk";
import type { Command } from "commander";
import {
queryActivityEvents,
searchActivityEvents,
getActivityEventStats,
droppedEventCount,
isActivityEventsFtsEnabled,
type ActivityEvent,
type ActivityEventLevel,
type ActivityEventKind,
} from "@aoagents/ao-core";
interface JsonEnvelope {
version: number;
query: Record<string, unknown>;
meta: {
resultCount: number;
droppedEventCount: number;
ftsEnabled: boolean;
fallbackUsed: boolean;
ts: string;
};
events: Record<string, unknown>[];
}
function toJsonOutput(ev: ActivityEvent): Record<string, unknown> {
let data: unknown = ev.data;
if (typeof ev.data === "string") {
try {
data = JSON.parse(ev.data);
} catch {
// leave as raw string if not valid JSON
}
}
return { ...ev, data };
}
function parseSinceDuration(raw: string): Date | undefined {
const match = raw.match(/^(\d+)(m|h|d)$/);
if (!match) return undefined;
const value = parseInt(match[1], 10);
const unit = match[2];
const ms = unit === "m" ? value * 60_000 : unit === "h" ? value * 3_600_000 : value * 86_400_000;
return new Date(Date.now() - ms);
}
function formatRow(ev: ActivityEvent): string {
const ts = new Date(ev.tsEpoch).toLocaleTimeString();
const session = ev.sessionId ? ev.sessionId.slice(0, 12) : "—";
const kind = chalk.cyan(ev.kind.padEnd(22));
// Pad raw string before chalk-wrapping: chalk adds ANSI codes that inflate .length
const levelLabel = ev.level.padEnd(9);
const level =
ev.level === "error"
? chalk.red(levelLabel)
: ev.level === "warn"
? chalk.yellow(levelLabel)
: chalk.gray(levelLabel);
return `${chalk.dim(ts)} ${kind} ${level} ${chalk.dim(session)} ${ev.summary}`;
}
function jsonMeta(resultCount: number, fallbackUsed = false): JsonEnvelope["meta"] {
return {
resultCount,
droppedEventCount: droppedEventCount(),
ftsEnabled: isActivityEventsFtsEnabled(),
fallbackUsed,
ts: new Date().toISOString(),
};
}
export function registerEvents(program: Command): void {
const events = program
.command("events")
.description("Query activity event log (session spawns, transitions, CI failures)");
events
.command("list")
.description("List recent activity events")
.option("-p, --project <id>", "Filter by project ID")
.option("-s, --session <id>", "Filter by session ID")
.option("-t, --type <kind>", "Filter by event kind (e.g. session.spawned, lifecycle.transition)")
.option("--log-level <level>", "Filter by log level (debug, info, warn, error)")
.option("--since <duration>", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)")
.option("-n, --limit <n>", "Max results", "50")
.option("--json", "Output as JSON")
.action(async (opts: Record<string, string | undefined>) => {
const sinceRaw = opts["since"];
let since: Date | undefined;
if (sinceRaw) {
since = parseSinceDuration(sinceRaw);
if (!since) {
console.error(chalk.yellow(`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`));
}
}
const limit = parseInt(opts["limit"] ?? "50", 10);
const results = queryActivityEvents({
projectId: opts["project"],
sessionId: opts["session"],
kind: opts["type"] as ActivityEventKind,
level: opts["logLevel"] as ActivityEventLevel,
since,
limit,
});
if (opts["json"]) {
const envelope: JsonEnvelope = {
version: 1,
query: {
projectId: opts["project"] ?? null,
sessionId: opts["session"] ?? null,
kind: opts["type"] ?? null,
level: opts["logLevel"] ?? null,
since: sinceRaw ?? null,
limit,
},
meta: jsonMeta(results.length),
events: results.map(toJsonOutput),
};
console.log(JSON.stringify(envelope, null, 2));
return;
}
if (results.length === 0) {
console.log(chalk.dim("No events found."));
return;
}
console.log(
chalk.dim(
`${"TIME".padEnd(10)} ${"KIND".padEnd(22)} ${"LEVEL".padEnd(9)} ${"SESSION".padEnd(12)} SUMMARY`,
),
);
for (const ev of results) {
console.log(formatRow(ev));
}
console.log(chalk.dim(`\n${results.length} event(s)`));
});
events
.command("search <query>")
.description("Full-text search across event summaries and data")
.option("-p, --project <id>", "Filter by project ID")
.option("-n, --limit <n>", "Max results", "100")
.option("--json", "Output as JSON")
.action(async (query: string, opts: Record<string, string | undefined>) => {
const limit = parseInt(opts["limit"] ?? "100", 10);
const results = searchActivityEvents(query, opts["project"], limit);
const fallbackUsed = !isActivityEventsFtsEnabled();
if (opts["json"]) {
const envelope: JsonEnvelope = {
version: 1,
query: { q: query, projectId: opts["project"] ?? null, limit },
meta: jsonMeta(results.length, fallbackUsed),
events: results.map(toJsonOutput),
};
console.log(JSON.stringify(envelope, null, 2));
return;
}
if (results.length === 0) {
console.log(chalk.dim("No events found."));
return;
}
console.log(
chalk.dim(
`${"TIME".padEnd(10)} ${"KIND".padEnd(22)} ${"LEVEL".padEnd(9)} ${"SESSION".padEnd(12)} SUMMARY`,
),
);
for (const ev of results) {
console.log(formatRow(ev));
}
console.log(chalk.dim(`\n${results.length} event(s)`));
});
events
.command("stats")
.description("Show event log statistics")
.action(async () => {
const stats = getActivityEventStats();
if (!stats) {
console.log(chalk.yellow("Event log unavailable (better-sqlite3 not loaded)."));
return;
}
console.log(chalk.bold("Event Log Stats"));
console.log(` Total events: ${stats.total}`);
console.log(` Dropped (process): ${stats.droppedThisProcess}`);
if (stats.oldestTs) console.log(` Oldest event: ${stats.oldestTs}`);
if (stats.newestTs) console.log(` Newest event: ${stats.newestTs}`);
if (Object.keys(stats.byKind).length > 0) {
console.log(chalk.bold("\nBy kind:"));
const byKind = Object.entries(stats.byKind) as [string, number][];
for (const [kind, count] of byKind.sort((a, b) => b[1] - a[1])) {
console.log(` ${kind.padEnd(30)} ${count}`);
}
}
if (Object.keys(stats.bySource).length > 0) {
console.log(chalk.bold("\nBy source:"));
const bySource = Object.entries(stats.bySource) as [string, number][];
for (const [source, count] of bySource.sort((a, b) => b[1] - a[1])) {
console.log(` ${source.padEnd(30)} ${count}`);
}
}
});
}

View File

@ -17,6 +17,7 @@ import { registerPlugin } from "./commands/plugin.js";
import { registerProjectCommand } from "./commands/project.js";
import { registerMigrateStorage } from "./commands/migrate-storage.js";
import { registerCompletion } from "./commands/completion.js";
import { registerEvents } from "./commands/events.js";
import { getConfigInstruction } from "./lib/config-instruction.js";
import { getCliVersion } from "./options/version.js";
@ -49,6 +50,7 @@ export function createProgram(): Command {
registerProjectCommand(program);
registerMigrateStorage(program);
registerCompletion(program);
registerEvents(program);
program
.command("config-help")

View File

@ -90,8 +90,12 @@
"yaml": "^2.7.0",
"zod": "^3.24.0"
},
"optionalDependencies": {
"better-sqlite3": "^11.0.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.3.0",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^25.2.3",
"@vitest/coverage-v8": "^4.0.18",
"rollup": "^4.60.1",

View File

@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
// Mock events-db before importing activity-events so getDb is controllable
vi.mock("../events-db.js", () => {
const rows: unknown[] = [];
const mockDb = {
prepare: (sql: string) => ({
run: (..._args: unknown[]) => {
if (sql.includes("INSERT INTO activity_events")) {
rows.push(_args);
}
},
all: () => [],
}),
};
return {
getDb: vi.fn(() => mockDb),
__rows: rows,
};
});
import { recordActivityEvent, droppedEventCount } from "../activity-events.js";
import * as eventsDb from "../events-db.js";
describe("recordActivityEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("inserts an event when DB is available", () => {
recordActivityEvent({
projectId: "proj-1",
sessionId: "sess-1",
source: "lifecycle",
kind: "lifecycle.transition",
summary: "working → pr_open",
data: { from: "working", to: "pr_open" },
});
// getDb was called
expect(eventsDb.getDb).toHaveBeenCalled();
});
it("increments droppedEventCount when DB returns null", () => {
const before = droppedEventCount();
vi.mocked(eventsDb.getDb).mockReturnValueOnce(null);
recordActivityEvent({
source: "lifecycle",
kind: "session.spawned",
summary: "spawned: sess-x",
});
expect(droppedEventCount()).toBe(before + 1);
});
it("never throws even if prepare throws", () => {
const badDb = {
prepare: () => {
throw new Error("disk full");
},
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(badDb as any);
expect(() =>
recordActivityEvent({
source: "session-manager",
kind: "session.killed",
summary: "killed: sess-1",
}),
).not.toThrow();
});
it("never throws even if data sanitization throws", () => {
const data = {};
Object.defineProperty(data, "bad", {
enumerable: true,
get: () => {
throw new Error("getter failed");
},
});
expect(() =>
recordActivityEvent({
source: "session-manager",
kind: "session.spawned",
summary: "spawned",
data,
}),
).not.toThrow();
});
it("sanitizes sensitive data keys", () => {
let capturedData: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedData = args[8]; // data is 9th param (index 8)
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
recordActivityEvent({
source: "lifecycle",
kind: "session.spawned",
summary: "spawned",
data: { token: "secret123", agent: "claude-code" },
});
const parsed = JSON.parse(capturedData as string);
expect(parsed["token"]).toBe("[redacted]");
expect(parsed["agent"]).toBe("claude-code");
});
it("sanitizes nested sensitive data keys and credential URLs", () => {
let capturedData: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedData = args[8];
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
recordActivityEvent({
source: "lifecycle",
kind: "session.spawned",
summary: "spawned",
data: {
request: {
headers: {
authorization: "Bearer ghp_secret",
url: "HTTPS://token@example.com/path",
},
},
},
});
const parsed = JSON.parse(capturedData as string);
expect(parsed["request"]["headers"]["authorization"]).toBe("[redacted]");
expect(parsed["request"]["headers"]["url"]).toBe("https://[redacted]@example.com/path");
});
it("preserves error messages that mention sensitive words in values", () => {
// Greptile flagged this as a bug: values like "token expired" or
// "authorization header missing" would be redacted. They are not —
// SENSITIVE_KEY_RE only matches key names, not string values.
let capturedData: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedData = args[8];
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
recordActivityEvent({
source: "session-manager",
kind: "session.spawn_failed",
summary: "spawn failed",
data: {
reason: "token expired",
message: "authorization header missing",
agent: "claude-code",
},
});
const parsed = JSON.parse(capturedData as string);
expect(parsed["reason"]).toBe("token expired");
expect(parsed["message"]).toBe("authorization header missing");
expect(parsed["agent"]).toBe("claude-code");
});
it("handles BigInt in data without throwing", () => {
let capturedData: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedData = args[8];
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
expect(() =>
recordActivityEvent({
source: "lifecycle",
kind: "session.spawned",
summary: "spawned",
data: { big: BigInt(9007199254740991) as any },
}),
).not.toThrow();
expect(typeof capturedData).toBe("string");
});
it("truncates summary to 500 chars", () => {
let capturedSummary: unknown;
const captureDb = {
prepare: (_sql: string) => ({
run: (...args: unknown[]) => {
capturedSummary = args[7]; // summary is 8th param (index 7)
},
all: () => [],
}),
};
vi.mocked(eventsDb.getDb).mockReturnValueOnce(captureDb as any);
const longSummary = "x".repeat(600);
recordActivityEvent({
source: "lifecycle",
kind: "lifecycle.transition",
summary: longSummary,
});
expect((capturedSummary as string).length).toBe(500);
expect(capturedSummary).toMatch(/\.\.\.$/);
});
});

View File

@ -444,7 +444,7 @@ describe("applyAgentReport", () => {
).toThrow(/not found/);
});
it("bounds the on-disk audit trail to recent entries", () => {
it("bounds the on-disk audit trail to recent entries", { timeout: 15000 }, () => {
for (let index = 0; index < 260; index += 1) {
applyAgentReport(dataDir, sessionId, {
state: "working",

View File

@ -0,0 +1,171 @@
/**
* Integration test for FTS5 search using a real in-memory SQLite database.
* Verifies that the FTS virtual table, triggers, and MATCH query actually work.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequire } from "node:module";
// We need a real better-sqlite3 instance for this test.
// If it's not available, skip.
let Database: (new (path: string) => any) | null = null;
try {
const require = createRequire(import.meta.url);
Database = require("better-sqlite3") as new (path: string) => any;
} catch {
// better-sqlite3 unavailable — integration tests will be skipped
}
// Mock events-db to inject our real in-memory DB
vi.mock("../events-db.js", () => ({
getDb: vi.fn(),
isActivityEventsFtsEnabled: vi.fn(() => true),
}));
import * as eventsDb from "../events-db.js";
import { recordActivityEvent } from "../activity-events.js";
import { searchActivityEvents, queryActivityEvents } from "../query-activity-events.js";
function openMemoryDb(): any {
if (!Database) throw new Error("better-sqlite3 not available");
const db = new (Database as any)(":memory:");
db.exec(`
CREATE TABLE IF NOT EXISTS activity_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_epoch INTEGER NOT NULL,
ts TEXT NOT NULL,
project_id TEXT,
session_id TEXT,
source TEXT NOT NULL,
type TEXT NOT NULL,
log_level TEXT NOT NULL DEFAULT 'info',
summary TEXT NOT NULL,
data TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS activity_events_fts USING fts5(
summary, data,
content='activity_events',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS activity_events_ai
AFTER INSERT ON activity_events
BEGIN
INSERT INTO activity_events_fts(rowid, summary, data)
VALUES (new.id, new.summary, new.data);
END;
CREATE TRIGGER IF NOT EXISTS activity_events_ad
AFTER DELETE ON activity_events
BEGIN
INSERT INTO activity_events_fts(activity_events_fts, rowid, summary, data)
VALUES ('delete', old.id, old.summary, old.data);
END;
CREATE TRIGGER IF NOT EXISTS activity_events_au
AFTER UPDATE ON activity_events
BEGIN
INSERT INTO activity_events_fts(activity_events_fts, rowid, summary, data)
VALUES ('delete', old.id, old.summary, old.data);
INSERT INTO activity_events_fts(rowid, summary, data)
VALUES (new.id, new.summary, new.data);
END;
CREATE INDEX IF NOT EXISTS idx_ae_ts ON activity_events(ts_epoch);
CREATE INDEX IF NOT EXISTS idx_ae_session ON activity_events(session_id);
CREATE INDEX IF NOT EXISTS idx_ae_project ON activity_events(project_id);
CREATE INDEX IF NOT EXISTS idx_ae_type ON activity_events(type);
CREATE INDEX IF NOT EXISTS idx_ae_source ON activity_events(source);
PRAGMA user_version = 1;
`);
return db;
}
const itIfAvailable = Database ? it : it.skip;
describe("FTS5 integration (real SQLite)", () => {
let db: ReturnType<typeof openMemoryDb>;
beforeEach(() => {
vi.clearAllMocks();
if (!Database) return;
db = openMemoryDb();
vi.mocked(eventsDb.getDb).mockReturnValue(db as any);
});
itIfAvailable("inserts and FTS-searches events end-to-end", () => {
recordActivityEvent({
source: "session-manager",
kind: "session.spawned",
summary: "spawned agent for feature-branch",
data: { agent: "claude-code", branch: "feat/my-feature" },
});
recordActivityEvent({
source: "lifecycle",
kind: "lifecycle.transition",
summary: "working → pr_open",
data: { from: "working", to: "pr_open" },
});
const results = searchActivityEvents("spawned");
expect(results).toHaveLength(1);
expect(results[0]!.kind).toBe("session.spawned");
expect(results[0]!.summary).toContain("spawned");
});
itIfAvailable("FTS search finds terms in data field", () => {
recordActivityEvent({
source: "session-manager",
kind: "session.spawned",
summary: "spawned",
data: { agent: "claude-code", branch: "feat/my-feature" },
});
const results = searchActivityEvents("claude");
expect(results).toHaveLength(1);
});
itIfAvailable("FTS search returns [] for non-matching term", () => {
recordActivityEvent({
source: "lifecycle",
kind: "lifecycle.transition",
summary: "working → pr_open",
});
const results = searchActivityEvents("nonexistentterm12345");
expect(results).toHaveLength(0);
});
itIfAvailable("FTS search filters by projectId in SQL (before LIMIT)", () => {
recordActivityEvent({
projectId: "proj-a",
source: "lifecycle",
kind: "session.spawned",
summary: "spawned in proj-a",
});
recordActivityEvent({
projectId: "proj-b",
source: "lifecycle",
kind: "session.spawned",
summary: "spawned in proj-b",
});
const results = searchActivityEvents("spawned", "proj-a");
expect(results).toHaveLength(1);
expect(results[0]!.projectId).toBe("proj-a");
});
itIfAvailable("queryActivityEvents returns events with epoch filter", () => {
const past = Date.now() - 60_000;
db.prepare(
`INSERT INTO activity_events (ts_epoch, ts, source, type, log_level, summary)
VALUES (?, ?, 'lifecycle', 'session.spawned', 'info', 'old event')`,
).run(past, new Date(past).toISOString());
recordActivityEvent({
source: "lifecycle",
kind: "session.spawned",
summary: "new event",
});
const recent = queryActivityEvents({ since: new Date(Date.now() - 1000) });
expect(recent.length).toBeGreaterThanOrEqual(1);
expect(recent.some((e) => e.summary === "new event")).toBe(true);
expect(recent.every((e) => e.summary !== "old event")).toBe(true);
});
});

View File

@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createLifecycleManager } from "../lifecycle-manager.js";
import { recordActivityEvent } from "../activity-events.js";
import { DEFAULT_BUGBOT_COMMENTS_MESSAGE } from "../config.js";
import {
resolvePREnrichmentDecision,
@ -34,6 +35,10 @@ import {
type MockPlugins,
} from "./test-utils.js";
vi.mock("../activity-events.js", () => ({
recordActivityEvent: vi.fn(),
}));
let env: TestEnvironment;
let plugins: MockPlugins;
let mockRegistry: PluginRegistry;
@ -46,6 +51,7 @@ beforeEach(() => {
mockRegistry = createMockRegistry({ runtime: plugins.runtime, agent: plugins.agent });
mockSessionManager = createMockSessionManager();
config = env.config;
vi.mocked(recordActivityEvent).mockClear();
});
afterEach(() => {
@ -278,6 +284,44 @@ describe("check (single session)", () => {
expect(meta!["status"]).toBe("working");
});
it("records lifecycle.transition when status changes", async () => {
const lm = setupCheck("app-1", {
session: makeSession({ status: "spawning" }),
});
await lm.check("app-1");
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "my-app",
sessionId: "app-1",
source: "lifecycle",
kind: "lifecycle.transition",
level: "info",
summary: "spawning → working",
data: { from: "spawning", to: "working" },
});
});
it("records activity.transition after observed activity changes", async () => {
const session = makeSession({ id: "app-activity", status: "working" });
const lm = setupCheck("app-activity", { session });
await lm.check("app-activity");
vi.mocked(recordActivityEvent).mockClear();
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "idle" });
await lm.check("app-activity");
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "my-app",
sessionId: "app-activity",
source: "lifecycle",
kind: "activity.transition",
summary: "active → idle",
data: { from: "active", to: "idle" },
});
});
it("records split lifecycle observability for transitions", async () => {
const lm = setupCheck("app-1", {
session: makeSession({ status: "spawning" }),
@ -1236,12 +1280,12 @@ describe("check (single session)", () => {
try {
lm.start(60_000);
await new Promise((resolve) => setTimeout(resolve, 25));
const adoptedCount = [sessionA.branch, sessionB.branch].filter(
(branch) => branch === "shared-branch",
).length;
expect(adoptedCount).toBe(1);
await vi.waitFor(() => {
const adoptedCount = [sessionA.branch, sessionB.branch].filter(
(branch) => branch === "shared-branch",
).length;
expect(adoptedCount).toBe(1);
});
expect(mockSessionManager.list).toHaveBeenCalledTimes(1);
} finally {
lm.stop();

View File

@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../events-db.js", () => ({
getDb: vi.fn(),
isActivityEventsFtsEnabled: vi.fn(() => true),
}));
vi.mock("../activity-events.js", async (importOriginal) => {
const mod = await (importOriginal as () => Promise<any>)();
return { ...mod, droppedEventCount: () => 0 };
});
import { queryActivityEvents, searchActivityEvents, getActivityEventStats } from "../query-activity-events.js";
import * as eventsDb from "../events-db.js";
const sampleRow = {
id: 1,
ts_epoch: Date.now(),
ts: new Date().toISOString(),
project_id: "proj-1",
session_id: "sess-1",
source: "lifecycle",
type: "lifecycle.transition",
log_level: "info",
summary: "working → pr_open",
data: JSON.stringify({ from: "working", to: "pr_open" }),
};
function makeDb(rows: unknown[] = [sampleRow]) {
return {
prepare: () => ({
all: (..._args: unknown[]) => rows,
run: () => {},
}),
};
}
describe("queryActivityEvents", () => {
beforeEach(() => vi.clearAllMocks());
it("returns [] when DB is null", () => {
vi.mocked(eventsDb.getDb).mockReturnValue(null);
expect(queryActivityEvents()).toEqual([]);
});
it("maps rows to ActivityEvent shape", () => {
vi.mocked(eventsDb.getDb).mockReturnValue(makeDb() as any);
const results = queryActivityEvents();
expect(results).toHaveLength(1);
expect(results[0]!.kind).toBe("lifecycle.transition");
expect(results[0]!.projectId).toBe("proj-1");
});
it("clamps limit to max 1000", () => {
let capturedSql = "";
const captureDb = {
prepare: (sql: string) => {
capturedSql = sql;
return { all: () => [] };
},
};
vi.mocked(eventsDb.getDb).mockReturnValue(captureDb as any);
queryActivityEvents({ limit: 9999 });
expect(capturedSql).toContain("LIMIT ?");
});
it("returns [] on DB error", () => {
const badDb = {
prepare: () => ({
all: () => {
throw new Error("disk error");
},
}),
};
vi.mocked(eventsDb.getDb).mockReturnValue(badDb as any);
expect(queryActivityEvents()).toEqual([]);
});
});
describe("searchActivityEvents", () => {
beforeEach(() => vi.clearAllMocks());
it("returns [] for empty query", () => {
vi.mocked(eventsDb.getDb).mockReturnValue(makeDb() as any);
expect(searchActivityEvents("")).toEqual([]);
expect(searchActivityEvents(" ")).toEqual([]);
});
it("sanitizes query — only word characters reach FTS", () => {
let capturedFtsQuery = "";
const captureDb = {
prepare: () => ({
all: (q: string) => {
capturedFtsQuery = q;
return [];
},
}),
};
vi.mocked(eventsDb.getDb).mockReturnValue(captureDb as any);
searchActivityEvents("spawn; DROP TABLE activity_events--");
// SQL injection stripped; only word tokens joined by AND
expect(capturedFtsQuery).toBe('"spawn" AND "DROP" AND "TABLE" AND "activity_events"');
});
it("returns [] when DB is null", () => {
vi.mocked(eventsDb.getDb).mockReturnValue(null);
expect(searchActivityEvents("spawned")).toEqual([]);
});
it("uses a bounded LIKE fallback when FTS is unavailable", () => {
let capturedSql = "";
let capturedArgs: unknown[] = [];
const captureDb = {
prepare: (sql: string) => {
capturedSql = sql;
return {
all: (...args: unknown[]) => {
capturedArgs = args;
return [sampleRow];
},
};
},
};
vi.mocked(eventsDb.getDb).mockReturnValue(captureDb as any);
vi.mocked(eventsDb.isActivityEventsFtsEnabled).mockReturnValueOnce(false);
const results = searchActivityEvents("spawn failed", "proj-1", 10);
expect(results).toHaveLength(1);
expect(capturedSql).toContain("LIKE");
expect(capturedSql).toContain("ae.project_id = ?");
expect(capturedArgs).toEqual(["%spawn failed%", "%spawn failed%", "proj-1", 10]);
});
});
describe("getActivityEventStats", () => {
beforeEach(() => vi.clearAllMocks());
it("returns null when DB is null", () => {
vi.mocked(eventsDb.getDb).mockReturnValue(null);
expect(getActivityEventStats()).toBeNull();
});
it("aggregates byKind and bySource", () => {
let callCount = 0;
const stubDb = {
prepare: () => ({
all: () => {
callCount++;
if (callCount === 1) return [{ cnt: 5 }]; // total
if (callCount === 2)
return [
{ type: "lifecycle.transition", cnt: 3 },
{ type: "session.spawned", cnt: 2 },
];
if (callCount === 3)
return [
{ source: "lifecycle", cnt: 4 },
{ source: "session-manager", cnt: 1 },
];
return [{ oldest: "2026-01-01T00:00:00Z", newest: "2026-04-27T00:00:00Z" }];
},
}),
};
vi.mocked(eventsDb.getDb).mockReturnValue(stubDb as any);
const stats = getActivityEventStats();
expect(stats).not.toBeNull();
expect(stats!.total).toBe(5);
expect(stats!.byKind["lifecycle.transition"]).toBe(3);
expect(stats!.bySource["lifecycle"]).toBe(4);
});
});

View File

@ -1,9 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createSessionManager } from "../session-manager.js";
import { writeMetadata } from "../metadata.js";
import { recordActivityEvent } from "../activity-events.js";
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js";
vi.mock("../activity-events.js", () => ({
recordActivityEvent: vi.fn(),
}));
// Mock child_process module with custom promisify
vi.mock("node:child_process", () => {
const execFileMock = vi.fn() as any;
@ -32,6 +37,7 @@ let config: OrchestratorConfig;
beforeEach(() => {
ctx = setupTestContext();
({ sessionsDir, mockRegistry, config } = ctx);
vi.mocked(recordActivityEvent).mockClear();
// Create an opencode agent mock
const opencodeAgent: Agent = {
@ -64,6 +70,88 @@ afterEach(() => {
vi.useRealTimers();
});
describe("activity event logging", () => {
it("records session.spawned after a successful spawn", async () => {
const { execFile } = await import("node:child_process");
vi.mocked(execFile).mockImplementation(((_file: string, _args: string[], options: any, callback?: any) => {
const cb = typeof options === "function" ? options : callback;
if (cb) cb(null, "", "");
return null as any;
}) as any);
vi.useFakeTimers();
config.projects["my-app"]!.agent = "mock-agent";
const sm = createSessionManager({ config, registry: mockRegistry });
const spawnPromise = sm.spawn({ projectId: "my-app" });
await vi.runAllTimersAsync();
const session = await spawnPromise;
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "my-app",
source: "session-manager",
kind: "session.spawn_started",
summary: "spawn started",
data: { agent: undefined },
});
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "my-app",
sessionId: session.id,
source: "session-manager",
kind: "session.spawned",
summary: `spawned: ${session.id}`,
data: { agent: "mock-agent", branch: session.branch },
});
});
it("records session.spawn_failed when spawn fails", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await expect(sm.spawn({ projectId: "missing-project", prompt: "nope" })).rejects.toThrow(
"Unknown project: missing-project",
);
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "missing-project",
source: "session-manager",
kind: "session.spawn_started",
summary: "spawn started",
data: { agent: undefined },
});
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "missing-project",
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: "spawn failed",
data: { reason: "Unknown project: missing-project" },
});
});
it("records session.killed after successful kill cleanup", async () => {
writeMetadata(sessionsDir, "app-kill", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
agent: "opencode",
runtimeHandle: makeHandle("rt-kill"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.kill("app-kill");
expect(recordActivityEvent).toHaveBeenCalledWith({
projectId: "my-app",
sessionId: "app-kill",
source: "session-manager",
kind: "session.killed",
summary: "killed: app-kill",
data: { reason: "manually_killed" },
});
});
});
describe("deleteSession retry loop", () => {
it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => {
const { execFile } = await import("node:child_process");

View File

@ -0,0 +1,160 @@
/**
* Activity event logging write API.
*
* recordActivityEvent() is synchronous and best-effort: it never throws.
* If the DB is unavailable or a write fails, the event is dropped and
* droppedEventCount is incremented.
*
* droppedEventCount is process-local. Events dropped in other processes
* (web server, lifecycle manager) are not reflected here.
*/
import { getDb } from "./events-db.js";
// Distinct names to avoid collision with types.ts EventType / EventSource.
export type ActivityEventSource = "lifecycle" | "session-manager" | "api" | "ui";
export type ActivityEventKind =
| "session.spawn_started"
| "session.spawned"
| "session.spawn_failed"
| "session.killed"
| "activity.transition"
| "lifecycle.transition"
| "ci.failing"
| "review.pending";
export type ActivityEventLevel = "debug" | "info" | "warn" | "error";
export interface ActivityEventInput {
projectId?: string;
sessionId?: string;
source: ActivityEventSource | string;
kind: ActivityEventKind | string;
level?: ActivityEventLevel;
summary: string;
data?: Record<string, unknown>;
}
export interface ActivityEvent {
id: number;
tsEpoch: number;
ts: string;
projectId: string | null;
sessionId: string | null;
source: string;
kind: string;
level: string;
summary: string;
data: string | null;
rank?: number;
}
let _droppedEventCount = 0;
let _lastPruneMs = 0;
const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const PRUNE_BATCH_SIZE = 1000;
/** Number of events dropped due to DB errors in this process. */
export function droppedEventCount(): number {
return _droppedEventCount;
}
function pruneOldEvents(db: ReturnType<typeof getDb>, cutoff: number): void {
db
?.prepare(
`DELETE FROM activity_events
WHERE rowid IN (
SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ?
)`,
)
.run(cutoff, PRUNE_BATCH_SIZE);
}
// Patterns that indicate sensitive field names
const SENSITIVE_KEY_RE = /token|password|secret|authorization|cookie|api[-_]?key/i;
// URL credentials: https://token@host or http://user:pass@host
const CREDENTIAL_URL_RE = /https?:\/\/[^@\s]+@/gi;
function sanitizeValue(value: unknown, seen: WeakSet<object>): unknown {
if (typeof value === "bigint") return value.toString();
if (typeof value === "string") return value.replace(CREDENTIAL_URL_RE, "https://[redacted]@");
if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return "[circular]";
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, seen));
}
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
cleaned[k] = SENSITIVE_KEY_RE.test(k) ? "[redacted]" : sanitizeValue(v, seen);
}
return cleaned;
}
function sanitizeData(data: Record<string, unknown>): string | undefined {
const cleaned = sanitizeValue(data, new WeakSet<object>());
let json: string;
try {
json = JSON.stringify(cleaned);
} catch {
return undefined;
}
// Reject if over 16 KB after sanitization (slicing would produce malformed JSON)
if (json.length > 16 * 1024) {
return undefined;
}
return json;
}
function sanitizeSummary(summary: string): string {
if (summary.length <= 500) return summary;
return `${summary.slice(0, 497)}...`;
}
/**
* Record an activity event. Synchronous, best-effort never throws.
*/
export function recordActivityEvent(event: ActivityEventInput): void {
try {
const db = getDb();
if (!db) {
_droppedEventCount++;
return;
}
const now = Date.now();
const ts = new Date(now).toISOString();
const summary = sanitizeSummary(event.summary);
const data = event.data ? sanitizeData(event.data) : undefined;
db.prepare(
`INSERT INTO activity_events
(ts_epoch, ts, project_id, session_id, source, type, log_level, summary, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
now,
ts,
event.projectId ?? null,
event.sessionId ?? null,
event.source,
event.kind,
event.level ?? "info",
summary,
data ?? null,
);
// Periodically purge old events so long-lived processes don't grow the DB indefinitely
if (now - _lastPruneMs >= PRUNE_INTERVAL_MS) {
_lastPruneMs = now;
pruneOldEvents(db, now - RETENTION_MS);
}
} catch {
_droppedEventCount++;
}
}

View File

@ -0,0 +1,159 @@
/**
* SQLite database layer for activity event logging.
*
* Lazy-initialized singleton. Opens on first call to getDb(), never on import.
* Returns null if better-sqlite3 is unavailable (native build failure, optional dep).
* WAL mode + busy_timeout for multi-process concurrent access.
*/
import { createRequire } from "node:module";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { getAoBaseDir } from "./paths.js";
// Use createRequire so we can try/catch on native module load without top-level await.
const _require = createRequire(import.meta.url);
type BetterSqlite3Database = {
pragma(source: string, options?: { simple?: boolean }): unknown;
exec(source: string): void;
prepare(source: string): { run(...args: unknown[]): unknown; all(...args: unknown[]): unknown[] };
close(): void;
};
let _db: BetterSqlite3Database | null = null;
let _dbFailed = false;
let _ftsEnabled = false;
const PRUNE_BATCH_SIZE = 1000;
function getEventsDbPath(): string {
return join(getAoBaseDir(), "activity-events.db");
}
function initSchema(db: BetterSqlite3Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS activity_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_epoch INTEGER NOT NULL,
ts TEXT NOT NULL,
project_id TEXT,
session_id TEXT,
source TEXT NOT NULL,
type TEXT NOT NULL,
log_level TEXT NOT NULL DEFAULT 'info',
summary TEXT NOT NULL,
data TEXT
);
CREATE INDEX IF NOT EXISTS idx_ae_ts ON activity_events(ts_epoch);
CREATE INDEX IF NOT EXISTS idx_ae_session ON activity_events(session_id);
CREATE INDEX IF NOT EXISTS idx_ae_project ON activity_events(project_id);
CREATE INDEX IF NOT EXISTS idx_ae_type ON activity_events(type);
CREATE INDEX IF NOT EXISTS idx_ae_source ON activity_events(source);
`);
}
function initFts(db: BetterSqlite3Database): void {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS activity_events_fts USING fts5(
summary, data,
content='activity_events',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS activity_events_ai
AFTER INSERT ON activity_events
BEGIN
INSERT INTO activity_events_fts(rowid, summary, data)
VALUES (new.id, new.summary, new.data);
END;
CREATE TRIGGER IF NOT EXISTS activity_events_ad
AFTER DELETE ON activity_events
BEGIN
INSERT INTO activity_events_fts(activity_events_fts, rowid, summary, data)
VALUES ('delete', old.id, old.summary, old.data);
END;
CREATE TRIGGER IF NOT EXISTS activity_events_au
AFTER UPDATE ON activity_events
BEGIN
INSERT INTO activity_events_fts(activity_events_fts, rowid, summary, data)
VALUES ('delete', old.id, old.summary, old.data);
INSERT INTO activity_events_fts(rowid, summary, data)
VALUES (new.id, new.summary, new.data);
END;
`);
// Backfill rows written before FTS was available; triggers only cover future writes.
db.exec("INSERT INTO activity_events_fts(activity_events_fts) VALUES('rebuild')");
}
function pruneOldEvents(db: BetterSqlite3Database, cutoff: number): void {
db
.prepare(
`DELETE FROM activity_events
WHERE rowid IN (
SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ?
)`,
)
.run(cutoff, PRUNE_BATCH_SIZE);
}
function openDb(): BetterSqlite3Database {
const Database = _require("better-sqlite3") as new (path: string) => BetterSqlite3Database;
mkdirSync(getAoBaseDir(), { recursive: true });
const db = new Database(getEventsDbPath());
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 3000");
// WAL + NORMAL gives one checkpoint window of exposure (acceptable for a diagnostic log)
db.pragma("synchronous = NORMAL");
const version = db.pragma("user_version", { simple: true }) as number;
initSchema(db);
if (version < 1) {
db.pragma("user_version = 1");
}
try {
initFts(db);
_ftsEnabled = true;
} catch (err) {
_ftsEnabled = false;
// eslint-disable-next-line no-console
console.warn(
"[ao] activity-events FTS unavailable — writes will continue and search will use a bounded LIKE fallback:",
err instanceof Error ? err.message : String(err),
);
}
// 7-day retention using epoch comparison (no text/datetime ambiguity)
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
pruneOldEvents(db, cutoff);
return db;
}
/** Whether the current process has an initialized FTS5 search table. */
export function isActivityEventsFtsEnabled(): boolean {
return _ftsEnabled;
}
/**
* Get the lazily-initialized DB connection.
* Returns null if better-sqlite3 failed to load or init callers should treat null as no-op.
*/
export function getDb(): BetterSqlite3Database | null {
if (_dbFailed) return null;
if (_db) return _db;
try {
_db = openDb();
return _db;
} catch (err) {
_dbFailed = true;
// Log once so operators know events are being dropped; subsequent calls return null silently.
console.warn("[ao] activity-events DB unavailable — events will be dropped:", err instanceof Error ? err.message : String(err));
return null;
}
}

View File

@ -368,3 +368,20 @@ export type {
} from "./migration/storage-v2.js";
export { atomicWriteFileSync } from "./atomic-write.js";
// Activity event logging — structured diagnostic event trail
export { recordActivityEvent, droppedEventCount } from "./activity-events.js";
export { isActivityEventsFtsEnabled } from "./events-db.js";
export type {
ActivityEventInput,
ActivityEventKind,
ActivityEventSource,
ActivityEventLevel,
ActivityEvent,
} from "./activity-events.js";
export {
queryActivityEvents,
searchActivityEvents,
getActivityEventStats,
} from "./query-activity-events.js";
export type { ActivityEventFilter, ActivityEventStats } from "./query-activity-events.js";

View File

@ -13,10 +13,12 @@
import { randomUUID } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { recordActivityEvent } from "./activity-events.js";
import {
ACTIVITY_STATE,
SESSION_STATUS,
TERMINAL_STATUSES,
type ActivityState,
type LifecycleManager,
type OpenCodeSessionManager,
type SessionId,
@ -474,6 +476,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const observer = createProjectObserver(config, "lifecycle-manager");
const states = new Map<SessionId, SessionStatus>();
const activityStateCache = new Map<string, ActivityState>(); // sessionId → last observed activity
const reactionTrackers = new Map<string, ReactionTracker>(); // "sessionId:reactionKey"
let pollTimer: ReturnType<typeof setInterval> | null = null;
let polling = false; // re-entrancy guard
@ -945,6 +948,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
activitySignal = classifyActivitySignal(detectedActivity, "native");
activityEvidence = formatActivitySignalEvidence(activitySignal);
lifecycle.runtime.lastObservedAt = nowIso;
const prevActivity = activityStateCache.get(session.id);
activityStateCache.set(session.id, detectedActivity.state);
if (prevActivity !== undefined && prevActivity !== detectedActivity.state) {
recordActivityEvent({
projectId: session.projectId,
sessionId: session.id,
source: "lifecycle",
kind: "activity.transition",
summary: `${prevActivity}${detectedActivity.state}`,
data: { from: prevActivity, to: detectedActivity.state },
});
}
if (lifecycle.runtime.state !== "missing" && lifecycle.runtime.state !== "probe_failed") {
lifecycle.runtime.state = "alive";
lifecycle.runtime.reason = "process_running";
@ -2098,6 +2113,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// State transition detected
states.set(session.id, newStatus);
updateSessionMetadata(session, { status: newStatus });
recordActivityEvent({
projectId: session.projectId,
sessionId: session.id,
source: "lifecycle",
kind: "lifecycle.transition",
level: newStatus === "ci_failed" ? "warn" : "info",
summary: `${oldStatus}${newStatus}`,
data: { from: oldStatus, to: newStatus },
});
observer.recordOperation({
metric: "lifecycle_poll",
operation: "lifecycle.transition",
@ -2413,6 +2437,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
states.delete(trackedId);
}
}
for (const trackedId of activityStateCache.keys()) {
if (!currentSessionIds.has(trackedId)) {
activityStateCache.delete(trackedId);
}
}
for (const trackerKey of reactionTrackers.keys()) {
const sessionId = trackerKey.split(":")[0];
if (sessionId && !currentSessionIds.has(sessionId)) {

View File

@ -0,0 +1,211 @@
/**
* Activity event logging read API.
*
* queryActivityEvents: structured filter-based retrieval.
* searchActivityEvents: FTS5 natural-language search.
* getActivityEventStats: aggregate counts for `ao events stats`.
*/
import { getDb, isActivityEventsFtsEnabled } from "./events-db.js";
import {
droppedEventCount,
type ActivityEvent,
type ActivityEventKind,
type ActivityEventSource,
type ActivityEventLevel,
} from "./activity-events.js";
export interface ActivityEventFilter {
projectId?: string;
sessionId?: string;
kind?: ActivityEventKind | string;
source?: ActivityEventSource;
level?: ActivityEventLevel;
since?: Date;
until?: Date;
limit?: number;
}
export interface ActivityEventStats {
total: number;
byKind: Record<string, number>;
bySource: Record<string, number>;
droppedThisProcess: number;
oldestTs?: string;
newestTs?: string;
}
function rowToEvent(row: Record<string, unknown>): ActivityEvent {
return {
id: row["id"] as number,
tsEpoch: row["ts_epoch"] as number,
ts: row["ts"] as string,
projectId: (row["project_id"] as string | null) ?? null,
sessionId: (row["session_id"] as string | null) ?? null,
source: row["source"] as string,
kind: row["type"] as string,
level: row["log_level"] as string,
summary: row["summary"] as string,
data: (row["data"] as string | null) ?? null,
rank: typeof row["rank"] === "number" ? (row["rank"] as number) : undefined,
};
}
function escapeLike(raw: string): string {
return raw.replace(/[\\%_]/g, "\\$&");
}
/**
* Query events with structured filters. Returns [] if DB is unavailable.
*/
export function queryActivityEvents(filter: ActivityEventFilter = {}): ActivityEvent[] {
const db = getDb();
if (!db) return [];
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.projectId) {
conditions.push("project_id = ?");
params.push(filter.projectId);
}
if (filter.sessionId) {
conditions.push("session_id = ?");
params.push(filter.sessionId);
}
if (filter.kind) {
conditions.push("type = ?");
params.push(filter.kind);
}
if (filter.source) {
conditions.push("source = ?");
params.push(filter.source);
}
if (filter.level) {
conditions.push("log_level = ?");
params.push(filter.level);
}
if (filter.since) {
conditions.push("ts_epoch >= ?");
params.push(filter.since.getTime());
}
if (filter.until) {
conditions.push("ts_epoch <= ?");
params.push(filter.until.getTime());
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rawLimit = filter.limit ?? 100;
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1000)) : 100;
try {
const rows = db
.prepare(`SELECT * FROM activity_events ${where} ORDER BY ts_epoch DESC LIMIT ?`)
.all(...params, limit) as Record<string, unknown>[];
return rows.map(rowToEvent);
} catch {
return [];
}
}
/**
* FTS5 natural-language search. Sanitizes the query to prevent injection.
* Returns [] if DB is unavailable or search fails.
* projectId filter is pushed into SQL so it applies before the LIMIT.
*/
export function searchActivityEvents(rawQuery: string, projectId?: string, limit = 100): ActivityEvent[] {
const db = getDb();
if (!db) return [];
// Only allow word characters; join with AND to prevent FTS syntax injection
const tokens = rawQuery.match(/\w+/g);
if (!tokens || tokens.length === 0) return [];
const ftsQuery = tokens.map((t) => `"${t}"`).join(" AND ");
const projectFilter = projectId ? "AND ae.project_id = ?" : "";
const clampedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 1000)) : 100;
const params: unknown[] = [];
if (projectId) params.push(projectId);
params.push(clampedLimit);
try {
if (!isActivityEventsFtsEnabled()) {
const likePattern = `%${escapeLike(tokens.join(" "))}%`;
const fallbackParams: unknown[] = [likePattern, likePattern];
if (projectId) fallbackParams.push(projectId);
fallbackParams.push(clampedLimit);
const rows = db
.prepare(
`SELECT ae.*, NULL AS rank FROM activity_events ae
WHERE (ae.summary LIKE ? ESCAPE '\\' OR ae.data LIKE ? ESCAPE '\\') ${projectFilter}
ORDER BY ae.ts_epoch DESC
LIMIT ?`,
)
.all(...fallbackParams) as Record<string, unknown>[];
return rows.map(rowToEvent);
}
params.unshift(ftsQuery);
const rows = db
.prepare(
`SELECT ae.*, activity_events_fts.rank AS rank FROM activity_events ae
JOIN activity_events_fts ON activity_events_fts.rowid = ae.id
WHERE activity_events_fts MATCH ? ${projectFilter}
ORDER BY activity_events_fts.rank
LIMIT ?`,
)
.all(...params) as Record<string, unknown>[];
return rows.map(rowToEvent);
} catch {
return [];
}
}
/**
* Aggregate stats for `ao events stats`. Returns null if DB is unavailable.
*/
export function getActivityEventStats(): ActivityEventStats | null {
const db = getDb();
if (!db) return null;
try {
const totalRow = db.prepare("SELECT COUNT(*) as cnt FROM activity_events").all() as Record<
string,
unknown
>[];
const total = (totalRow[0]?.["cnt"] as number) ?? 0;
const byKindRows = db
.prepare("SELECT type, COUNT(*) as cnt FROM activity_events GROUP BY type")
.all() as Record<string, unknown>[];
const byKind: Record<string, number> = {};
for (const row of byKindRows) {
byKind[row["type"] as string] = row["cnt"] as number;
}
const bySourceRows = db
.prepare("SELECT source, COUNT(*) as cnt FROM activity_events GROUP BY source")
.all() as Record<string, unknown>[];
const bySource: Record<string, number> = {};
for (const row of bySourceRows) {
bySource[row["source"] as string] = row["cnt"] as number;
}
const rangeRow = db
.prepare("SELECT MIN(ts) as oldest, MAX(ts) as newest FROM activity_events")
.all() as Record<string, unknown>[];
return {
total,
byKind,
bySource,
droppedThisProcess: droppedEventCount(),
oldestTs: (rangeRow[0]?.["oldest"] as string | null) ?? undefined,
newestTs: (rangeRow[0]?.["newest"] as string | null) ?? undefined,
};
} catch {
return null;
}
}
export type { ActivityEvent, ActivityEventKind, ActivityEventSource, ActivityEventLevel };

View File

@ -12,6 +12,7 @@
*/
import { statSync, existsSync, writeFileSync, mkdirSync, utimesSync, unlinkSync } from "node:fs";
import { recordActivityEvent } from "./activity-events.js";
import { execFile } from "node:child_process";
import { basename, join, resolve } from "node:path";
import { homedir } from "node:os";
@ -1070,6 +1071,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// Define methods as local functions so `this` is not needed
async function spawn(spawnConfig: SessionSpawnConfig): Promise<Session> {
recordActivityEvent({
projectId: spawnConfig.projectId,
source: "session-manager",
kind: "session.spawn_started",
summary: "spawn started",
data: { agent: spawnConfig.agent ?? undefined },
});
try {
return await _spawnInner(spawnConfig);
} catch (err) {
recordActivityEvent({
projectId: spawnConfig.projectId,
source: "session-manager",
kind: "session.spawn_failed",
level: "error",
summary: `spawn failed`,
data: { reason: err instanceof Error ? err.message : String(err) },
});
throw err;
}
}
async function _spawnInner(spawnConfig: SessionSpawnConfig): Promise<Session> {
const project = config.projects[spawnConfig.projectId];
if (!project) {
throw new Error(`Unknown project: ${spawnConfig.projectId}`);
@ -1469,6 +1494,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
invalidateCache();
}
recordActivityEvent({
projectId: spawnConfig.projectId,
sessionId,
source: "session-manager",
kind: "session.spawned",
summary: `spawned: ${sessionId}`,
data: { agent: plugins.agent.name, branch: session.branch ?? undefined },
});
return session;
}
@ -2130,6 +2164,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
});
invalidateCache();
recordActivityEvent({
projectId,
sessionId,
source: "session-manager",
kind: "session.killed",
summary: `killed: ${sessionId}`,
data: { reason: killReason },
});
return { cleaned: true, alreadyTerminated: false };
}

View File

@ -1150,6 +1150,7 @@ export type EventPriority = "urgent" | "action" | "warning" | "info";
/** All orchestrator event types */
export type EventType =
// Session lifecycle
| "session.spawn_started"
| "session.spawned"
| "session.working"
| "session.exited"

View File

@ -161,10 +161,17 @@ importers:
zod:
specifier: ^3.24.0
version: 3.25.76
optionalDependencies:
better-sqlite3:
specifier: ^11.0.0
version: 11.10.0
devDependencies:
'@rollup/plugin-typescript':
specifier: ^12.3.0
version: 12.3.0(rollup@4.60.1)(tslib@2.8.1)(typescript@5.9.3)
'@types/better-sqlite3':
specifier: ^7.6.0
version: 7.6.13
'@types/node':
specifier: ^25.2.3
version: 25.6.0
@ -2030,6 +2037,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -2336,6 +2346,15 @@ packages:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
better-sqlite3@11.10.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
brace-expansion@2.0.3:
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
@ -2352,6 +2371,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@ -2397,6 +2419,9 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@ -2505,10 +2530,18 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -2564,6 +2597,9 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@ -2689,6 +2725,10 @@ packages:
resolution: {integrity: sha512-zwxwiQqexizSXFZV13zMiEtW1E3lv7RlUv+1f5FBiR4x7wFhEjm3aFTyYkZQWzyN08WnPdox015GoRH5D/E5YA==}
engines: {node: '>=18.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@ -2736,6 +2776,9 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@ -2772,6 +2815,9 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
@ -2820,6 +2866,9 @@ packages:
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -2907,6 +2956,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -2923,6 +2975,12 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inquirer@10.2.2:
resolution: {integrity: sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==}
engines: {node: '>=18'}
@ -3251,6 +3309,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@ -3263,6 +3325,9 @@ packages:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass-collect@2.0.1:
resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==}
engines: {node: '>=16 || 14 >=14.17'}
@ -3295,6 +3360,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@ -3319,6 +3387,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@ -3353,6 +3424,10 @@ packages:
sass:
optional: true
node-abi@3.89.0:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@ -3378,6 +3453,9 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
@ -3549,6 +3627,12 @@ packages:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -3575,6 +3659,9 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -3588,6 +3675,10 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@ -3608,6 +3699,10 @@ packages:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@ -3666,6 +3761,9 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@ -3711,6 +3809,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@ -3773,6 +3877,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@ -3789,6 +3896,10 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
@ -3827,6 +3938,13 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.13:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'}
@ -3914,6 +4032,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
@ -3956,6 +4077,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
@ -4179,6 +4303,9 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@7.5.10:
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
engines: {node: '>=8.3.0'}
@ -5415,6 +5542,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.6.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -5780,6 +5911,24 @@ snapshots:
dependencies:
is-windows: 1.0.2
better-sqlite3@11.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
optional: true
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
optional: true
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
@ -5800,6 +5949,12 @@ snapshots:
node-releases: 2.0.37
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
optional: true
cac@6.7.14: {}
cacache@20.0.4:
@ -5847,6 +6002,9 @@ snapshots:
check-error@2.1.3: {}
chownr@1.1.4:
optional: true
chownr@3.0.0: {}
cli-cursor@5.0.0:
@ -5951,8 +6109,16 @@ snapshots:
decimal.js@10.6.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
optional: true
deep-eql@5.0.2: {}
deep-extend@0.6.0:
optional: true
deep-is@0.1.4: {}
define-lazy-prop@2.0.0: {}
@ -5991,6 +6157,11 @@ snapshots:
emoji-regex@9.2.2: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
optional: true
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@ -6170,6 +6341,9 @@ snapshots:
eventsource-parser@3.0.7: {}
expand-template@2.0.3:
optional: true
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@ -6216,6 +6390,9 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-uri-to-path@1.0.0:
optional: true
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@ -6252,6 +6429,9 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
fs-constants@1.0.0:
optional: true
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
@ -6304,6 +6484,9 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0:
optional: true
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -6394,6 +6577,9 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1:
optional: true
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -6402,6 +6588,12 @@ snapshots:
indent-string@4.0.0: {}
inherits@2.0.4:
optional: true
ini@1.3.8:
optional: true
inquirer@10.2.2:
dependencies:
'@inquirer/core': 9.2.1
@ -6717,6 +6909,9 @@ snapshots:
mimic-function@5.0.1: {}
mimic-response@3.1.0:
optional: true
min-indent@1.0.1: {}
minimatch@10.2.5:
@ -6727,6 +6922,9 @@ snapshots:
dependencies:
brace-expansion: 2.0.3
minimist@1.2.8:
optional: true
minipass-collect@2.0.1:
dependencies:
minipass: 7.1.3
@ -6761,6 +6959,9 @@ snapshots:
dependencies:
minipass: 7.1.3
mkdirp-classic@0.5.3:
optional: true
mri@1.2.0: {}
mrmime@2.0.1: {}
@ -6773,6 +6974,9 @@ snapshots:
nanoid@3.3.11: {}
napi-build-utils@2.0.0:
optional: true
natural-compare@1.4.0: {}
negotiator@1.0.0: {}
@ -6806,6 +7010,11 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-abi@3.89.0:
dependencies:
semver: 7.7.4
optional: true
node-addon-api@7.1.1:
optional: true
@ -6839,6 +7048,11 @@ snapshots:
obug@2.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
optional: true
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
@ -6993,6 +7207,22 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.89.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {}
prettier@2.8.8: {}
@ -7009,6 +7239,12 @@ snapshots:
proxy-from-env@2.1.0: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
optional: true
punycode@2.3.1: {}
pusher-js@8.4.0-rc2:
@ -7019,6 +7255,14 @@ snapshots:
queue-microtask@1.2.3: {}
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
optional: true
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
@ -7037,6 +7281,13 @@ snapshots:
pify: 4.0.1
strip-bom: 3.0.0
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@ -7116,6 +7367,9 @@ snapshots:
dependencies:
tslib: 2.8.1
safe-buffer@5.2.1:
optional: true
safer-buffer@2.1.2: {}
saxes@6.0.0:
@ -7174,6 +7428,16 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1:
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
optional: true
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
@ -7238,6 +7502,11 @@ snapshots:
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
optional: true
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@ -7252,6 +7521,9 @@ snapshots:
dependencies:
min-indent: 1.0.1
strip-json-comments@2.0.1:
optional: true
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
@ -7279,6 +7551,23 @@ snapshots:
tapable@2.3.2: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
optional: true
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
tar@7.5.13:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@ -7351,6 +7640,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
tweetnacl@1.0.3: {}
type-check@0.4.0:
@ -7388,6 +7682,9 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2:
optional: true
uuid@10.0.0: {}
uuid@11.1.0: {}
@ -7588,6 +7885,9 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
wrappy@1.0.2:
optional: true
ws@7.5.10: {}
ws@8.20.0: {}