/** * Event repository — CRUD operations for events table. * Extracted from SqliteEventStore (Story S-7.4). */ import { eq, and, gte, lte, desc, asc, sql, count as drizzleCount } from 'drizzle-orm'; import { computeEventHash, pricingVersion } from '@agentkitai/agentlens-core'; import type { AgentLensEvent, EventQuery, EventQueryResult, ChainEvent } from '@agentkitai/agentlens-core'; import type { SqliteDb } from '../index.js'; import { events, sessions, agents } from '../schema.sqlite.js'; import { HashChainError } from '../errors.js'; import { buildEventConditions, mapEventRow, safeJsonParse } from '../shared/query-helpers.js'; import { metadataVerifiedAgentId } from '../../lib/agent-identity.js'; import { applyRollupBatch } from '../../lib/rollup.js'; import { createLogger } from '../../lib/logger.js'; const log = createLogger('EventRepository'); /** Cost-bearing event types — the only ones that carry priced spend / pricing provenance (#89). */ export const COST_EVENT_TYPES = new Set(['cost_tracked', 'llm_response']); export class EventRepository { constructor(private db: SqliteDb) {} private warnIfNoTenant(method: string, tenantId?: string): void { if (tenantId === undefined) { log.warn( `${method}() called without tenantId — query is unscoped. ` + `Ensure tenant isolation is applied upstream (via TenantScopedStore).`, ); } } insertEvents( eventList: AgentLensEvent[], handleSessionUpdate: (tx: any, event: AgentLensEvent, tenantId: string) => void, handleAgentUpsert: (tx: any, event: AgentLensEvent, tenantId: string) => void, ): void { if (eventList.length === 0) return; // Pricing provenance (#89): the active pricing fingerprint at ingest, stamped // on cost-bearing events so reconciliation can detect stored cost that // predates a price change. Resolved once per batch. const pv = pricingVersion(); this.db.transaction((tx) => { const firstEvent = eventList[0]!; const tenantId = firstEvent.tenantId ?? 'default'; const existingFirst = tx .select({ id: events.id }) .from(events) .where(eq(events.id, firstEvent.id)) .get(); // Continuity guard only applies to *chained* events (non-null prevHash). // OTLP-ingested telemetry is unchained (prevHash=null) — see otlp.ts — and // is appended freely; its per-event hash is still validated below. if (!existingFirst && firstEvent.prevHash !== null) { const lastStoredEvent = tx .select({ hash: events.hash }) .from(events) .where(and(eq(events.sessionId, firstEvent.sessionId), eq(events.tenantId, tenantId))) .orderBy(desc(events.timestamp), desc(events.id)) .limit(1) .get(); const lastStoredHash = lastStoredEvent?.hash ?? null; if (firstEvent.prevHash !== lastStoredHash) { throw new HashChainError( `Chain continuity broken: event ${firstEvent.id} has prevHash=${firstEvent.prevHash} but last stored hash is ${lastStoredHash}`, ); } } for (let i = 0; i < eventList.length; i++) { const event = eventList[i]!; // Linkage enforced only for chained events (non-null prevHash). if (i > 0 && event.prevHash !== null) { const prevEvent = eventList[i - 1]!; if (event.prevHash !== prevEvent.hash) { throw new HashChainError( `Chain continuity broken within batch: event ${event.id} has prevHash=${event.prevHash} but previous event hash is ${prevEvent.hash}`, ); } } const recomputedHash = computeEventHash({ id: event.id, timestamp: event.timestamp, sessionId: event.sessionId, agentId: event.agentId, eventType: event.eventType, severity: event.severity, payload: event.payload, metadata: event.metadata, prevHash: event.prevHash, }); if (event.hash !== recomputedHash) { throw new HashChainError( `Hash mismatch for event ${event.id}: supplied=${event.hash}, computed=${recomputedHash}`, ); } } for (const event of eventList) { const tenantId = event.tenantId ?? 'default'; tx.insert(events) .values({ id: event.id, timestamp: event.timestamp, sessionId: event.sessionId, agentId: event.agentId, eventType: event.eventType, severity: event.severity, payload: JSON.stringify(event.payload), metadata: JSON.stringify(event.metadata), prevHash: event.prevHash, hash: event.hash, tenantId, // org→project scoping (#147): stamp the scope's org/project when the // caller (TenantScopedStore) provides it; else default org, project==tenant. orgId: event.orgId ?? 'default', projectId: event.projectId ?? tenantId, // Derived projection of the hashed metadata — never hashed itself (#87). verifiedAgentId: metadataVerifiedAgentId(event.metadata), // Pricing provenance for cost-bearing events only (#89). pricingVersion: COST_EVENT_TYPES.has(event.eventType) ? pv : null, }) .onConflictDoNothing({ target: events.id }) .run(); handleSessionUpdate(tx, event, tenantId); handleAgentUpsert(tx, event, tenantId); } // Incremental rollup (#124) — best-effort: a rollup failure must never // break ingest (events above are already inserted in this tx). Backfill / // retention reconciles any gap. try { // eslint-disable-next-line @typescript-eslint/no-explicit-any applyRollupBatch(tx as any, eventList, pv); } catch (err) { log.warn(`rollup skipped: ${err instanceof Error ? err.message : String(err)}`); } }); } async queryEvents(query: EventQuery): Promise { const limit = Math.min(query.limit ?? 50, 500); const offset = query.offset ?? 0; const orderDir = query.order === 'asc' ? asc : desc; const conditions = buildEventConditions(query); const rows = this.db .select() .from(events) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(orderDir(events.timestamp)) .limit(limit) .offset(offset) .all(); const total = await this.countEvents(query); return { events: rows.map(mapEventRow), total, hasMore: offset + rows.length < total, }; } async getEvent(id: string, tenantId?: string, orgId?: string, projectId?: string): Promise { this.warnIfNoTenant('getEvent', tenantId); const conditions = [eq(events.id, id)]; if (tenantId) conditions.push(eq(events.tenantId, tenantId)); if (orgId) conditions.push(eq(events.orgId, orgId)); if (projectId) conditions.push(eq(events.projectId, projectId)); const row = this.db .select() .from(events) .where(and(...conditions)) .get(); return row ? mapEventRow(row) : null; } async getSessionTimeline(sessionId: string, tenantId?: string, orgId?: string, projectId?: string): Promise { const conditions = [eq(events.sessionId, sessionId)]; if (tenantId) conditions.push(eq(events.tenantId, tenantId)); if (orgId) conditions.push(eq(events.orgId, orgId)); if (projectId) conditions.push(eq(events.projectId, projectId)); const rows = this.db .select() .from(events) .where(and(...conditions)) // (timestamp, id) so the chain order is deterministic for events that // share a millisecond — required for hash-chain verification. .orderBy(asc(events.timestamp), asc(events.id)) .all(); return rows.map(mapEventRow); } async getLastEventHash(sessionId: string, tenantId?: string, orgId?: string, projectId?: string): Promise { const conditions = [eq(events.sessionId, sessionId)]; if (tenantId) conditions.push(eq(events.tenantId, tenantId)); if (orgId) conditions.push(eq(events.orgId, orgId)); if (projectId) conditions.push(eq(events.projectId, projectId)); const row = this.db .select({ hash: events.hash }) .from(events) .where(and(...conditions)) .orderBy(desc(events.timestamp), desc(events.id)) .limit(1) .get(); return row?.hash ?? null; } async countEvents(query: Omit): Promise { const conditions = buildEventConditions(query); const result = this.db .select({ count: drizzleCount() }) .from(events) .where(conditions.length > 0 ? and(...conditions) : undefined) .get(); return result?.count ?? 0; } /** * Get events for a session in batches, ordered by (timestamp ASC, id ASC). * Returns ChainEvent objects suitable for hash chain verification. */ getSessionEventsBatch( sessionId: string, tenantId: string, offset: number, limit: number, ): ChainEvent[] { const rows = this.db .select() .from(events) .where(and( eq(events.tenantId, tenantId), eq(events.sessionId, sessionId), )) .orderBy(asc(events.timestamp), asc(events.id)) .limit(limit) .offset(offset) .all(); return rows.map(row => ({ id: row.id, timestamp: row.timestamp, sessionId: row.sessionId, agentId: row.agentId, eventType: row.eventType, severity: row.severity, payload: safeJsonParse(row.payload, {}), metadata: safeJsonParse(row.metadata, {}), prevHash: row.prevHash, hash: row.hash, })); } /** * Get events for a session in batches with raw (pre-serialized) payload/metadata. * Avoids JSON parse+stringify overhead for verification workloads. */ getSessionEventsBatchRaw( sessionId: string, tenantId: string, offset: number, limit: number, ): import('@agentkitai/agentlens-core').RawChainEvent[] { const rows = this.db .select() .from(events) .where(and( eq(events.tenantId, tenantId), eq(events.sessionId, sessionId), )) .orderBy(asc(events.timestamp), asc(events.id)) .limit(limit) .offset(offset) .all(); return rows.map(row => ({ id: row.id, timestamp: row.timestamp, sessionId: row.sessionId, agentId: row.agentId, eventType: row.eventType, severity: row.severity, payloadRaw: row.payload ?? '{}', metadataRaw: row.metadata ?? '{}', prevHash: row.prevHash, hash: row.hash, })); } /** * Get distinct session IDs that have at least one event in [from, to]. */ getSessionIdsInRange( tenantId: string, from: string, to: string, ): string[] { const rows = this.db .selectDistinct({ sessionId: events.sessionId }) .from(events) .where(and( eq(events.tenantId, tenantId), gte(events.timestamp, from), lte(events.timestamp, to), )) .all(); return rows.map(r => r.sessionId); } /** * Tenant-wide batched event reader for compliance export. * Returns raw event rows across all sessions, ordered by timestamp ASC, id ASC. */ getEventsBatchByTenantAndRange( tenantId: string, from: string, to: string, offset: number, limit: number, ): { id: string; timestamp: string; sessionId: string; agentId: string; eventType: string; severity: string; payload: string; metadata: string; prevHash: string | null; hash: string }[] { const rows = this.db .select() .from(events) .where(and( eq(events.tenantId, tenantId), gte(events.timestamp, from), lte(events.timestamp, to), )) .orderBy(asc(events.timestamp), asc(events.id)) .limit(limit) .offset(offset) .all(); return rows.map(row => ({ id: row.id, timestamp: row.timestamp, sessionId: row.sessionId, agentId: row.agentId, eventType: row.eventType, severity: row.severity, payload: row.payload, metadata: row.metadata ?? '{}', prevHash: row.prevHash, hash: row.hash, })); } /** * Events for ONE verified agent identity across sessions in a time range, * tenant-scoped, ordered (timestamp, id). Drives the cross-product evidence * timeline (#98). Filtering on the server-derived verified_agent_id column * (never null-matched) excludes unattributed events by construction. */ getEventsBatchByTenantAgentAndRange( tenantId: string, verifiedAgentId: string, from: string, to: string, offset: number, limit: number, ): { id: string; timestamp: string; sessionId: string; agentId: string; eventType: string; severity: string; payload: string; metadata: string; prevHash: string | null; hash: string }[] { const rows = this.db .select() .from(events) .where(and( eq(events.tenantId, tenantId), eq(events.verifiedAgentId, verifiedAgentId), gte(events.timestamp, from), lte(events.timestamp, to), )) .orderBy(asc(events.timestamp), asc(events.id)) .limit(limit) .offset(offset) .all(); return rows.map(row => ({ id: row.id, timestamp: row.timestamp, sessionId: row.sessionId, agentId: row.agentId, eventType: row.eventType, severity: row.severity, payload: row.payload, metadata: row.metadata ?? '{}', prevHash: row.prevHash, hash: row.hash, })); } /** * Aggregate approval event stats for compliance report. */ getApprovalStats( tenantId: string, from: string, to: string, ): { total: number; granted: number; denied: number; expired: number; avgResponseTimeMs: number | null } { const result = this.db.get<{ total: number; granted: number; denied: number; expired: number; }>(sql` SELECT SUM(CASE WHEN event_type = 'approval_requested' THEN 1 ELSE 0 END) as total, SUM(CASE WHEN event_type = 'approval_granted' THEN 1 ELSE 0 END) as granted, SUM(CASE WHEN event_type = 'approval_denied' THEN 1 ELSE 0 END) as denied, SUM(CASE WHEN event_type = 'approval_expired' THEN 1 ELSE 0 END) as expired FROM events WHERE tenant_id = ${tenantId} AND timestamp >= ${from} AND timestamp <= ${to} AND event_type IN ('approval_requested', 'approval_granted', 'approval_denied', 'approval_expired') `); // Compute average response time from granted/denied events that have responseTimeMs in payload const avgResult = this.db.get<{ avg: number | null }>(sql` SELECT AVG(json_extract(payload, '$.responseTimeMs')) as avg FROM events WHERE tenant_id = ${tenantId} AND timestamp >= ${from} AND timestamp <= ${to} AND event_type IN ('approval_granted', 'approval_denied') AND json_extract(payload, '$.responseTimeMs') IS NOT NULL `); return { total: Number(result?.total ?? 0), granted: Number(result?.granted ?? 0), denied: Number(result?.denied ?? 0), expired: Number(result?.expired ?? 0), avgResponseTimeMs: avgResult?.avg != null ? Number(avgResult.avg) : null, }; } /** * Get incident events (error/critical severity or alert_triggered) for compliance report. */ getIncidentEvents( tenantId: string, from: string, to: string, limit: number = 1000, ): { id: string; timestamp: string; sessionId: string; agentId: string; eventType: string; severity: string; payload: string }[] { const rows = this.db.all<{ id: string; timestamp: string; session_id: string; agent_id: string; event_type: string; severity: string; payload: string; }>(sql` SELECT id, timestamp, session_id, agent_id, event_type, severity, payload FROM events WHERE tenant_id = ${tenantId} AND timestamp >= ${from} AND timestamp <= ${to} AND (severity IN ('error', 'critical') OR event_type = 'alert_triggered') ORDER BY timestamp DESC LIMIT ${limit} `); return rows.map(row => ({ id: row.id, timestamp: row.timestamp, sessionId: row.session_id, agentId: row.agent_id, eventType: row.event_type, severity: row.severity, payload: row.payload, })); } async countEventsBatch( query: { agentId: string; from: string; to: string; tenantId?: string; orgId?: string; projectId?: string }, ): Promise<{ total: number; error: number; critical: number; toolError: number }> { const conditions = []; if (query.tenantId) conditions.push(eq(events.tenantId, query.tenantId)); if (query.orgId) conditions.push(eq(events.orgId, query.orgId)); if (query.projectId) conditions.push(eq(events.projectId, query.projectId)); conditions.push(eq(events.agentId, query.agentId)); conditions.push(gte(events.timestamp, query.from)); conditions.push(lte(events.timestamp, query.to)); const result = this.db .select({ total: drizzleCount(), error: sql`SUM(CASE WHEN ${events.severity} = 'error' THEN 1 ELSE 0 END)`, critical: sql`SUM(CASE WHEN ${events.severity} = 'critical' THEN 1 ELSE 0 END)`, toolError: sql`SUM(CASE WHEN ${events.eventType} = 'tool_error' THEN 1 ELSE 0 END)`, }) .from(events) .where(and(...conditions)) .get(); return { total: result?.total ?? 0, error: result?.error ?? 0, critical: result?.critical ?? 0, toolError: result?.toolError ?? 0, }; } }