diff --git a/.changeset/activity-events-log.md b/.changeset/activity-events-log.md new file mode 100644 index 000000000..6fa912606 --- /dev/null +++ b/.changeset/activity-events-log.md @@ -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. diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts new file mode 100644 index 000000000..87430d2a3 --- /dev/null +++ b/packages/cli/src/commands/events.ts @@ -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; + meta: { + resultCount: number; + droppedEventCount: number; + ftsEnabled: boolean; + fallbackUsed: boolean; + ts: string; + }; + events: Record[]; +} + +function toJsonOutput(ev: ActivityEvent): Record { + 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 ", "Filter by project ID") + .option("-s, --session ", "Filter by session ID") + .option("-t, --type ", "Filter by event kind (e.g. session.spawned, lifecycle.transition)") + .option("--log-level ", "Filter by log level (debug, info, warn, error)") + .option("--since ", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)") + .option("-n, --limit ", "Max results", "50") + .option("--json", "Output as JSON") + .action(async (opts: Record) => { + 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 ") + .description("Full-text search across event summaries and data") + .option("-p, --project ", "Filter by project ID") + .option("-n, --limit ", "Max results", "100") + .option("--json", "Output as JSON") + .action(async (query: string, opts: Record) => { + 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}`); + } + } + }); +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index f5110afb9..5bf953159 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -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") diff --git a/packages/core/package.json b/packages/core/package.json index c17e14254..e34610b14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/__tests__/activity-events.test.ts b/packages/core/src/__tests__/activity-events.test.ts new file mode 100644 index 000000000..14bc39437 --- /dev/null +++ b/packages/core/src/__tests__/activity-events.test.ts @@ -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(/\.\.\.$/); + }); +}); diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index ca35e894f..d400add10 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -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", diff --git a/packages/core/src/__tests__/events-fts-integration.test.ts b/packages/core/src/__tests__/events-fts-integration.test.ts new file mode 100644 index 000000000..671ab5214 --- /dev/null +++ b/packages/core/src/__tests__/events-fts-integration.test.ts @@ -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; + + 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); + }); +}); diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index e60c8338d..bd5c82d66 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -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(); diff --git a/packages/core/src/__tests__/query-activity-events.test.ts b/packages/core/src/__tests__/query-activity-events.test.ts new file mode 100644 index 000000000..df4c5324e --- /dev/null +++ b/packages/core/src/__tests__/query-activity-events.test.ts @@ -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)(); + 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); + }); +}); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 7af72659b..0daf946ec 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -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"); diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts new file mode 100644 index 000000000..edff28d67 --- /dev/null +++ b/packages/core/src/activity-events.ts @@ -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; +} + +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, 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): 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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + cleaned[k] = SENSITIVE_KEY_RE.test(k) ? "[redacted]" : sanitizeValue(v, seen); + } + return cleaned; +} + +function sanitizeData(data: Record): string | undefined { + const cleaned = sanitizeValue(data, new WeakSet()); + + 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++; + } +} diff --git a/packages/core/src/events-db.ts b/packages/core/src/events-db.ts new file mode 100644 index 000000000..04d74725f --- /dev/null +++ b/packages/core/src/events-db.ts @@ -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; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d684b9a8..593b58523 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 61eba7f32..d6c3f6a26 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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(); + const activityStateCache = new Map(); // sessionId → last observed activity const reactionTrackers = new Map(); // "sessionId:reactionKey" let pollTimer: ReturnType | 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)) { diff --git a/packages/core/src/query-activity-events.ts b/packages/core/src/query-activity-events.ts new file mode 100644 index 000000000..3603e2286 --- /dev/null +++ b/packages/core/src/query-activity-events.ts @@ -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; + bySource: Record; + droppedThisProcess: number; + oldestTs?: string; + newestTs?: string; +} + +function rowToEvent(row: Record): 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[]; + 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[]; + 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[]; + 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[]; + const byKind: Record = {}; + 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[]; + const bySource: Record = {}; + 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[]; + + 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 }; diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 59be713e9..443317dbe 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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 { + 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 { 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 }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1dd5e0e90..0393e03ea 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8362c31e4..bffb42919 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}