@agentlensai/server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/__tests__/agents-stats.test.d.ts +5 -0
- package/dist/__tests__/agents-stats.test.d.ts.map +1 -0
- package/dist/__tests__/agents-stats.test.js +134 -0
- package/dist/__tests__/agents-stats.test.js.map +1 -0
- package/dist/__tests__/api-keys.test.d.ts +5 -0
- package/dist/__tests__/api-keys.test.d.ts.map +1 -0
- package/dist/__tests__/api-keys.test.js +118 -0
- package/dist/__tests__/api-keys.test.js.map +1 -0
- package/dist/__tests__/auth-no-db.test.d.ts +2 -0
- package/dist/__tests__/auth-no-db.test.d.ts.map +1 -0
- package/dist/__tests__/auth-no-db.test.js +43 -0
- package/dist/__tests__/auth-no-db.test.js.map +1 -0
- package/dist/__tests__/auth.test.d.ts +5 -0
- package/dist/__tests__/auth.test.d.ts.map +1 -0
- package/dist/__tests__/auth.test.js +86 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +37 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/events-ingest.test.d.ts +5 -0
- package/dist/__tests__/events-ingest.test.d.ts.map +1 -0
- package/dist/__tests__/events-ingest.test.js +248 -0
- package/dist/__tests__/events-ingest.test.js.map +1 -0
- package/dist/__tests__/events-query.test.d.ts +5 -0
- package/dist/__tests__/events-query.test.d.ts.map +1 -0
- package/dist/__tests__/events-query.test.js +205 -0
- package/dist/__tests__/events-query.test.js.map +1 -0
- package/dist/__tests__/health.test.d.ts +5 -0
- package/dist/__tests__/health.test.d.ts.map +1 -0
- package/dist/__tests__/health.test.js +40 -0
- package/dist/__tests__/health.test.js.map +1 -0
- package/dist/__tests__/sessions.test.d.ts +5 -0
- package/dist/__tests__/sessions.test.d.ts.map +1 -0
- package/dist/__tests__/sessions.test.js +176 -0
- package/dist/__tests__/sessions.test.js.map +1 -0
- package/dist/__tests__/test-helpers.d.ts +24 -0
- package/dist/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/__tests__/test-helpers.js +45 -0
- package/dist/__tests__/test-helpers.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/db/__tests__/init.test.d.ts +2 -0
- package/dist/db/__tests__/init.test.d.ts.map +1 -0
- package/dist/db/__tests__/init.test.js +73 -0
- package/dist/db/__tests__/init.test.js.map +1 -0
- package/dist/db/__tests__/sqlite-store-read.test.d.ts +2 -0
- package/dist/db/__tests__/sqlite-store-read.test.d.ts.map +1 -0
- package/dist/db/__tests__/sqlite-store-read.test.js +749 -0
- package/dist/db/__tests__/sqlite-store-read.test.js.map +1 -0
- package/dist/db/__tests__/sqlite-store-write.test.d.ts +2 -0
- package/dist/db/__tests__/sqlite-store-write.test.d.ts.map +1 -0
- package/dist/db/__tests__/sqlite-store-write.test.js +418 -0
- package/dist/db/__tests__/sqlite-store-write.test.js.map +1 -0
- package/dist/db/errors.d.ts +16 -0
- package/dist/db/errors.d.ts.map +1 -0
- package/dist/db/errors.js +22 -0
- package/dist/db/errors.js.map +1 -0
- package/dist/db/index.d.ts +33 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +44 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +26 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +128 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.sqlite.d.ts +1009 -0
- package/dist/db/schema.sqlite.d.ts.map +1 -0
- package/dist/db/schema.sqlite.js +96 -0
- package/dist/db/schema.sqlite.js.map +1 -0
- package/dist/db/sqlite-store.d.ts +68 -0
- package/dist/db/sqlite-store.d.ts.map +1 -0
- package/dist/db/sqlite-store.js +753 -0
- package/dist/db/sqlite-store.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/retention.test.d.ts +2 -0
- package/dist/lib/__tests__/retention.test.d.ts.map +1 -0
- package/dist/lib/__tests__/retention.test.js +238 -0
- package/dist/lib/__tests__/retention.test.js.map +1 -0
- package/dist/lib/retention.d.ts +31 -0
- package/dist/lib/retention.d.ts.map +1 -0
- package/dist/lib/retention.js +47 -0
- package/dist/lib/retention.js.map +1 -0
- package/dist/middleware/auth.d.ts +37 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +78 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/agents.d.ts +13 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +34 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/api-keys.d.ts +14 -0
- package/dist/routes/api-keys.d.ts.map +1 -0
- package/dist/routes/api-keys.js +81 -0
- package/dist/routes/api-keys.js.map +1 -0
- package/dist/routes/config.d.ts +39 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +97 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/events.d.ts +14 -0
- package/dist/routes/events.d.ts.map +1 -0
- package/dist/routes/events.js +164 -0
- package/dist/routes/events.js.map +1 -0
- package/dist/routes/sessions.d.ts +14 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +72 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stats.d.ts +12 -0
- package/dist/routes/stats.d.ts.map +1 -0
- package/dist/routes/stats.js +16 -0
- package/dist/routes/stats.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite implementation of IEventStore.
|
|
3
|
+
*
|
|
4
|
+
* Write operations (Story 3.4):
|
|
5
|
+
* - insertEvents() — batch insert in single transaction
|
|
6
|
+
* - Auto-create/update session records on event insert
|
|
7
|
+
* - Auto-create agent records on first event
|
|
8
|
+
* - Session status management
|
|
9
|
+
*
|
|
10
|
+
* Read operations (Story 3.5):
|
|
11
|
+
* - queryEvents(), getEvent(), getSessionTimeline(), countEvents()
|
|
12
|
+
* - querySessions(), getSession()
|
|
13
|
+
* - listAgents(), getAgent()
|
|
14
|
+
* - getAnalytics(), getStats()
|
|
15
|
+
*/
|
|
16
|
+
import { eq, and, gte, lte, inArray, desc, asc, sql, like, count as drizzleCount } from 'drizzle-orm';
|
|
17
|
+
import { computeEventHash } from '@agentlens/core';
|
|
18
|
+
import { events, sessions, agents, alertRules } from './schema.sqlite.js';
|
|
19
|
+
import { HashChainError, NotFoundError } from './errors.js';
|
|
20
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Safe JSON.parse that returns a fallback on any error instead of throwing.
|
|
23
|
+
*/
|
|
24
|
+
export function safeJsonParse(raw, fallback) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class SqliteEventStore {
|
|
33
|
+
db;
|
|
34
|
+
constructor(db) {
|
|
35
|
+
this.db = db;
|
|
36
|
+
}
|
|
37
|
+
// ─── Events — Write ────────────────────────────────────────
|
|
38
|
+
async insertEvents(eventList) {
|
|
39
|
+
if (eventList.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
// Batch insert in a single transaction for atomicity and performance
|
|
42
|
+
this.db.transaction((tx) => {
|
|
43
|
+
// CRITICAL 3: Validate hash chain before inserting
|
|
44
|
+
const firstEvent = eventList[0];
|
|
45
|
+
// Check if the first event already exists (idempotent re-insert)
|
|
46
|
+
const existingFirst = tx
|
|
47
|
+
.select({ id: events.id })
|
|
48
|
+
.from(events)
|
|
49
|
+
.where(eq(events.id, firstEvent.id))
|
|
50
|
+
.get();
|
|
51
|
+
if (!existingFirst) {
|
|
52
|
+
// Fresh insert — validate chain continuity against stored events
|
|
53
|
+
const lastStoredEvent = tx
|
|
54
|
+
.select({ hash: events.hash })
|
|
55
|
+
.from(events)
|
|
56
|
+
.where(eq(events.sessionId, firstEvent.sessionId))
|
|
57
|
+
.orderBy(desc(events.timestamp), desc(events.id))
|
|
58
|
+
.limit(1)
|
|
59
|
+
.get();
|
|
60
|
+
const lastStoredHash = lastStoredEvent?.hash ?? null;
|
|
61
|
+
// Validate that the first event's prevHash matches the latest stored hash
|
|
62
|
+
if (firstEvent.prevHash !== lastStoredHash) {
|
|
63
|
+
throw new HashChainError(`Chain continuity broken: event ${firstEvent.id} has prevHash=${firstEvent.prevHash} but last stored hash is ${lastStoredHash}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Validate each event's hash and intra-batch chain links
|
|
67
|
+
for (let i = 0; i < eventList.length; i++) {
|
|
68
|
+
const event = eventList[i];
|
|
69
|
+
// Verify intra-batch chain continuity (for events after the first)
|
|
70
|
+
if (i > 0) {
|
|
71
|
+
const prevEvent = eventList[i - 1];
|
|
72
|
+
if (event.prevHash !== prevEvent.hash) {
|
|
73
|
+
throw new HashChainError(`Chain continuity broken within batch: event ${event.id} has prevHash=${event.prevHash} but previous event hash is ${prevEvent.hash}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Recompute hash server-side and verify
|
|
77
|
+
const recomputedHash = computeEventHash({
|
|
78
|
+
id: event.id,
|
|
79
|
+
timestamp: event.timestamp,
|
|
80
|
+
sessionId: event.sessionId,
|
|
81
|
+
agentId: event.agentId,
|
|
82
|
+
eventType: event.eventType,
|
|
83
|
+
severity: event.severity,
|
|
84
|
+
payload: event.payload,
|
|
85
|
+
metadata: event.metadata,
|
|
86
|
+
prevHash: event.prevHash,
|
|
87
|
+
});
|
|
88
|
+
if (event.hash !== recomputedHash) {
|
|
89
|
+
throw new HashChainError(`Hash mismatch for event ${event.id}: supplied=${event.hash}, computed=${recomputedHash}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const event of eventList) {
|
|
93
|
+
// CRITICAL 4: Idempotent event insertion — ON CONFLICT DO NOTHING
|
|
94
|
+
tx.insert(events)
|
|
95
|
+
.values({
|
|
96
|
+
id: event.id,
|
|
97
|
+
timestamp: event.timestamp,
|
|
98
|
+
sessionId: event.sessionId,
|
|
99
|
+
agentId: event.agentId,
|
|
100
|
+
eventType: event.eventType,
|
|
101
|
+
severity: event.severity,
|
|
102
|
+
payload: JSON.stringify(event.payload),
|
|
103
|
+
metadata: JSON.stringify(event.metadata),
|
|
104
|
+
prevHash: event.prevHash,
|
|
105
|
+
hash: event.hash,
|
|
106
|
+
})
|
|
107
|
+
.onConflictDoNothing({ target: events.id })
|
|
108
|
+
.run();
|
|
109
|
+
// Handle session management
|
|
110
|
+
this._handleSessionUpdate(tx, event);
|
|
111
|
+
// Handle agent auto-creation
|
|
112
|
+
this._handleAgentUpsert(tx, event);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
_handleSessionUpdate(tx, event) {
|
|
117
|
+
if (event.eventType === 'session_started') {
|
|
118
|
+
// Create new session
|
|
119
|
+
const payload = event.payload;
|
|
120
|
+
const tags = payload.tags ?? [];
|
|
121
|
+
const agentName = payload.agentName ?? undefined;
|
|
122
|
+
// CRITICAL 4: Use INSERT ... ON CONFLICT DO UPDATE for sessions
|
|
123
|
+
tx.insert(sessions)
|
|
124
|
+
.values({
|
|
125
|
+
id: event.sessionId,
|
|
126
|
+
agentId: event.agentId,
|
|
127
|
+
agentName: agentName,
|
|
128
|
+
startedAt: event.timestamp,
|
|
129
|
+
status: 'active',
|
|
130
|
+
eventCount: 1,
|
|
131
|
+
toolCallCount: 0,
|
|
132
|
+
errorCount: 0,
|
|
133
|
+
totalCostUsd: 0,
|
|
134
|
+
tags: JSON.stringify(tags),
|
|
135
|
+
})
|
|
136
|
+
.onConflictDoUpdate({
|
|
137
|
+
target: sessions.id,
|
|
138
|
+
set: {
|
|
139
|
+
agentName: agentName ?? sql `coalesce(${sessions.agentName}, NULL)`,
|
|
140
|
+
status: 'active',
|
|
141
|
+
eventCount: sql `${sessions.eventCount} + 1`,
|
|
142
|
+
tags: tags.length > 0 ? JSON.stringify(tags) : sql `${sessions.tags}`,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
.run();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// For non-session_started events, ensure a session exists first (upsert)
|
|
149
|
+
tx.insert(sessions)
|
|
150
|
+
.values({
|
|
151
|
+
id: event.sessionId,
|
|
152
|
+
agentId: event.agentId,
|
|
153
|
+
startedAt: event.timestamp,
|
|
154
|
+
status: 'active',
|
|
155
|
+
eventCount: 0,
|
|
156
|
+
toolCallCount: 0,
|
|
157
|
+
errorCount: 0,
|
|
158
|
+
totalCostUsd: 0,
|
|
159
|
+
tags: '[]',
|
|
160
|
+
})
|
|
161
|
+
.onConflictDoNothing({ target: sessions.id })
|
|
162
|
+
.run();
|
|
163
|
+
// Build incremental updates
|
|
164
|
+
const isToolCall = event.eventType === 'tool_call';
|
|
165
|
+
const isError = event.severity === 'error' ||
|
|
166
|
+
event.severity === 'critical' ||
|
|
167
|
+
event.eventType === 'tool_error';
|
|
168
|
+
const isCost = event.eventType === 'cost_tracked';
|
|
169
|
+
const costPayload = event.payload;
|
|
170
|
+
const costUsd = isCost ? (Number(costPayload.costUsd) || 0) : 0;
|
|
171
|
+
if (event.eventType === 'session_ended') {
|
|
172
|
+
const payload = event.payload;
|
|
173
|
+
const reason = payload.reason;
|
|
174
|
+
const status = reason === 'error' ? 'error' : 'completed';
|
|
175
|
+
tx.update(sessions)
|
|
176
|
+
.set({
|
|
177
|
+
endedAt: event.timestamp,
|
|
178
|
+
status: status,
|
|
179
|
+
eventCount: sql `${sessions.eventCount} + 1`,
|
|
180
|
+
errorCount: isError
|
|
181
|
+
? sql `${sessions.errorCount} + 1`
|
|
182
|
+
: sessions.errorCount,
|
|
183
|
+
})
|
|
184
|
+
.where(eq(sessions.id, event.sessionId))
|
|
185
|
+
.run();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Regular event — increment counters
|
|
189
|
+
tx.update(sessions)
|
|
190
|
+
.set({
|
|
191
|
+
eventCount: sql `${sessions.eventCount} + 1`,
|
|
192
|
+
toolCallCount: isToolCall
|
|
193
|
+
? sql `${sessions.toolCallCount} + 1`
|
|
194
|
+
: sessions.toolCallCount,
|
|
195
|
+
errorCount: isError
|
|
196
|
+
? sql `${sessions.errorCount} + 1`
|
|
197
|
+
: sessions.errorCount,
|
|
198
|
+
totalCostUsd: isCost
|
|
199
|
+
? sql `${sessions.totalCostUsd} + ${costUsd}`
|
|
200
|
+
: sessions.totalCostUsd,
|
|
201
|
+
})
|
|
202
|
+
.where(eq(sessions.id, event.sessionId))
|
|
203
|
+
.run();
|
|
204
|
+
}
|
|
205
|
+
_handleAgentUpsert(tx, event) {
|
|
206
|
+
// CRITICAL 4: Use INSERT ... ON CONFLICT DO UPDATE for agents
|
|
207
|
+
const payload = event.payload;
|
|
208
|
+
const agentName = payload.agentName ?? event.agentId;
|
|
209
|
+
tx.insert(agents)
|
|
210
|
+
.values({
|
|
211
|
+
id: event.agentId,
|
|
212
|
+
name: agentName,
|
|
213
|
+
firstSeenAt: event.timestamp,
|
|
214
|
+
lastSeenAt: event.timestamp,
|
|
215
|
+
sessionCount: event.eventType === 'session_started' ? 1 : 0,
|
|
216
|
+
})
|
|
217
|
+
.onConflictDoUpdate({
|
|
218
|
+
target: agents.id,
|
|
219
|
+
set: {
|
|
220
|
+
lastSeenAt: event.timestamp,
|
|
221
|
+
sessionCount: event.eventType === 'session_started'
|
|
222
|
+
? sql `${agents.sessionCount} + 1`
|
|
223
|
+
: agents.sessionCount,
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
.run();
|
|
227
|
+
}
|
|
228
|
+
// ─── Sessions — Write ──────────────────────────────────────
|
|
229
|
+
async upsertSession(session) {
|
|
230
|
+
const existing = this.db
|
|
231
|
+
.select()
|
|
232
|
+
.from(sessions)
|
|
233
|
+
.where(eq(sessions.id, session.id))
|
|
234
|
+
.get();
|
|
235
|
+
if (existing) {
|
|
236
|
+
const updates = {};
|
|
237
|
+
if (session.agentId !== undefined)
|
|
238
|
+
updates.agentId = session.agentId;
|
|
239
|
+
if (session.agentName !== undefined)
|
|
240
|
+
updates.agentName = session.agentName;
|
|
241
|
+
if (session.startedAt !== undefined)
|
|
242
|
+
updates.startedAt = session.startedAt;
|
|
243
|
+
if (session.endedAt !== undefined)
|
|
244
|
+
updates.endedAt = session.endedAt;
|
|
245
|
+
if (session.status !== undefined)
|
|
246
|
+
updates.status = session.status;
|
|
247
|
+
if (session.eventCount !== undefined)
|
|
248
|
+
updates.eventCount = session.eventCount;
|
|
249
|
+
if (session.toolCallCount !== undefined)
|
|
250
|
+
updates.toolCallCount = session.toolCallCount;
|
|
251
|
+
if (session.errorCount !== undefined)
|
|
252
|
+
updates.errorCount = session.errorCount;
|
|
253
|
+
if (session.totalCostUsd !== undefined)
|
|
254
|
+
updates.totalCostUsd = session.totalCostUsd;
|
|
255
|
+
if (session.tags !== undefined)
|
|
256
|
+
updates.tags = JSON.stringify(session.tags);
|
|
257
|
+
if (Object.keys(updates).length > 0) {
|
|
258
|
+
this.db
|
|
259
|
+
.update(sessions)
|
|
260
|
+
.set(updates)
|
|
261
|
+
.where(eq(sessions.id, session.id))
|
|
262
|
+
.run();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
this.db
|
|
267
|
+
.insert(sessions)
|
|
268
|
+
.values({
|
|
269
|
+
id: session.id,
|
|
270
|
+
agentId: session.agentId ?? '',
|
|
271
|
+
startedAt: session.startedAt ?? new Date().toISOString(),
|
|
272
|
+
status: session.status ?? 'active',
|
|
273
|
+
agentName: session.agentName,
|
|
274
|
+
endedAt: session.endedAt,
|
|
275
|
+
eventCount: session.eventCount ?? 0,
|
|
276
|
+
toolCallCount: session.toolCallCount ?? 0,
|
|
277
|
+
errorCount: session.errorCount ?? 0,
|
|
278
|
+
totalCostUsd: session.totalCostUsd ?? 0,
|
|
279
|
+
tags: JSON.stringify(session.tags ?? []),
|
|
280
|
+
})
|
|
281
|
+
.run();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// ─── Agents — Write ────────────────────────────────────────
|
|
285
|
+
async upsertAgent(agent) {
|
|
286
|
+
const existing = this.db
|
|
287
|
+
.select()
|
|
288
|
+
.from(agents)
|
|
289
|
+
.where(eq(agents.id, agent.id))
|
|
290
|
+
.get();
|
|
291
|
+
if (existing) {
|
|
292
|
+
const updates = {};
|
|
293
|
+
if (agent.name !== undefined)
|
|
294
|
+
updates.name = agent.name;
|
|
295
|
+
if (agent.description !== undefined)
|
|
296
|
+
updates.description = agent.description;
|
|
297
|
+
if (agent.lastSeenAt !== undefined)
|
|
298
|
+
updates.lastSeenAt = agent.lastSeenAt;
|
|
299
|
+
if (agent.sessionCount !== undefined)
|
|
300
|
+
updates.sessionCount = agent.sessionCount;
|
|
301
|
+
if (Object.keys(updates).length > 0) {
|
|
302
|
+
this.db
|
|
303
|
+
.update(agents)
|
|
304
|
+
.set(updates)
|
|
305
|
+
.where(eq(agents.id, agent.id))
|
|
306
|
+
.run();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const now = new Date().toISOString();
|
|
311
|
+
this.db
|
|
312
|
+
.insert(agents)
|
|
313
|
+
.values({
|
|
314
|
+
id: agent.id,
|
|
315
|
+
name: agent.name ?? agent.id,
|
|
316
|
+
description: agent.description,
|
|
317
|
+
firstSeenAt: agent.firstSeenAt ?? now,
|
|
318
|
+
lastSeenAt: agent.lastSeenAt ?? now,
|
|
319
|
+
sessionCount: agent.sessionCount ?? 0,
|
|
320
|
+
})
|
|
321
|
+
.run();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// ─── Events — Read (Story 3.5 stubs, implemented later) ────
|
|
325
|
+
async queryEvents(query) {
|
|
326
|
+
const limit = Math.min(query.limit ?? 50, 500);
|
|
327
|
+
const offset = query.offset ?? 0;
|
|
328
|
+
const orderDir = query.order === 'asc' ? asc : desc;
|
|
329
|
+
const conditions = this._buildEventConditions(query);
|
|
330
|
+
const rows = this.db
|
|
331
|
+
.select()
|
|
332
|
+
.from(events)
|
|
333
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
334
|
+
.orderBy(orderDir(events.timestamp))
|
|
335
|
+
.limit(limit)
|
|
336
|
+
.offset(offset)
|
|
337
|
+
.all();
|
|
338
|
+
const total = await this.countEvents(query);
|
|
339
|
+
return {
|
|
340
|
+
events: rows.map(this._mapEventRow),
|
|
341
|
+
total,
|
|
342
|
+
hasMore: offset + rows.length < total,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async getEvent(id) {
|
|
346
|
+
const row = this.db
|
|
347
|
+
.select()
|
|
348
|
+
.from(events)
|
|
349
|
+
.where(eq(events.id, id))
|
|
350
|
+
.get();
|
|
351
|
+
return row ? this._mapEventRow(row) : null;
|
|
352
|
+
}
|
|
353
|
+
async getSessionTimeline(sessionId) {
|
|
354
|
+
const rows = this.db
|
|
355
|
+
.select()
|
|
356
|
+
.from(events)
|
|
357
|
+
.where(eq(events.sessionId, sessionId))
|
|
358
|
+
.orderBy(asc(events.timestamp))
|
|
359
|
+
.all();
|
|
360
|
+
return rows.map(this._mapEventRow);
|
|
361
|
+
}
|
|
362
|
+
async countEvents(query) {
|
|
363
|
+
const conditions = this._buildEventConditions(query);
|
|
364
|
+
const result = this.db
|
|
365
|
+
.select({ count: drizzleCount() })
|
|
366
|
+
.from(events)
|
|
367
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
368
|
+
.get();
|
|
369
|
+
return result?.count ?? 0;
|
|
370
|
+
}
|
|
371
|
+
// ─── Sessions — Read ───────────────────────────────────────
|
|
372
|
+
async querySessions(query) {
|
|
373
|
+
const limit = Math.min(query.limit ?? 50, 500);
|
|
374
|
+
const offset = query.offset ?? 0;
|
|
375
|
+
const conditions = this._buildSessionConditions(query);
|
|
376
|
+
const rows = this.db
|
|
377
|
+
.select()
|
|
378
|
+
.from(sessions)
|
|
379
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
380
|
+
.orderBy(desc(sessions.startedAt))
|
|
381
|
+
.limit(limit)
|
|
382
|
+
.offset(offset)
|
|
383
|
+
.all();
|
|
384
|
+
const totalResult = this.db
|
|
385
|
+
.select({ count: drizzleCount() })
|
|
386
|
+
.from(sessions)
|
|
387
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
388
|
+
.get();
|
|
389
|
+
return {
|
|
390
|
+
sessions: rows.map(this._mapSessionRow),
|
|
391
|
+
total: totalResult?.count ?? 0,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async getSession(id) {
|
|
395
|
+
const row = this.db
|
|
396
|
+
.select()
|
|
397
|
+
.from(sessions)
|
|
398
|
+
.where(eq(sessions.id, id))
|
|
399
|
+
.get();
|
|
400
|
+
return row ? this._mapSessionRow(row) : null;
|
|
401
|
+
}
|
|
402
|
+
// ─── Agents — Read ─────────────────────────────────────────
|
|
403
|
+
async listAgents() {
|
|
404
|
+
const rows = this.db
|
|
405
|
+
.select()
|
|
406
|
+
.from(agents)
|
|
407
|
+
.orderBy(desc(agents.lastSeenAt))
|
|
408
|
+
.all();
|
|
409
|
+
return rows.map(this._mapAgentRow);
|
|
410
|
+
}
|
|
411
|
+
async getAgent(id) {
|
|
412
|
+
const row = this.db
|
|
413
|
+
.select()
|
|
414
|
+
.from(agents)
|
|
415
|
+
.where(eq(agents.id, id))
|
|
416
|
+
.get();
|
|
417
|
+
return row ? this._mapAgentRow(row) : null;
|
|
418
|
+
}
|
|
419
|
+
// ─── Analytics ─────────────────────────────────────────────
|
|
420
|
+
async getAnalytics(params) {
|
|
421
|
+
// HIGH 5: Build the time-bucket format string for SQLite strftime
|
|
422
|
+
const formatStr = params.granularity === 'hour'
|
|
423
|
+
? '%Y-%m-%dT%H:00:00Z'
|
|
424
|
+
: params.granularity === 'day'
|
|
425
|
+
? '%Y-%m-%dT00:00:00Z'
|
|
426
|
+
: '%Y-%W'; // week — true ISO week bucketing
|
|
427
|
+
const conditions = [
|
|
428
|
+
gte(events.timestamp, params.from),
|
|
429
|
+
lte(events.timestamp, params.to),
|
|
430
|
+
];
|
|
431
|
+
if (params.agentId) {
|
|
432
|
+
conditions.push(eq(events.agentId, params.agentId));
|
|
433
|
+
}
|
|
434
|
+
// HIGH 6: Compute avgLatencyMs and totalCostUsd from payload JSON
|
|
435
|
+
// Bucketed query
|
|
436
|
+
const bucketRows = this.db
|
|
437
|
+
.all(sql `
|
|
438
|
+
SELECT
|
|
439
|
+
strftime(${formatStr}, timestamp) as bucket,
|
|
440
|
+
COUNT(*) as eventCount,
|
|
441
|
+
SUM(CASE WHEN event_type = 'tool_call' THEN 1 ELSE 0 END) as toolCallCount,
|
|
442
|
+
SUM(CASE WHEN severity IN ('error', 'critical') OR event_type = 'tool_error' THEN 1 ELSE 0 END) as errorCount,
|
|
443
|
+
COUNT(DISTINCT session_id) as uniqueSessions,
|
|
444
|
+
COALESCE(AVG(CASE WHEN event_type = 'tool_response' THEN json_extract(payload, '$.durationMs') ELSE NULL END), 0) as avgLatencyMs,
|
|
445
|
+
COALESCE(SUM(CASE WHEN event_type = 'cost_tracked' THEN json_extract(payload, '$.costUsd') ELSE 0 END), 0) as totalCostUsd
|
|
446
|
+
FROM events
|
|
447
|
+
WHERE timestamp >= ${params.from}
|
|
448
|
+
AND timestamp <= ${params.to}
|
|
449
|
+
${params.agentId ? sql `AND agent_id = ${params.agentId}` : sql ``}
|
|
450
|
+
GROUP BY bucket
|
|
451
|
+
ORDER BY bucket ASC
|
|
452
|
+
`);
|
|
453
|
+
// Totals query
|
|
454
|
+
const totalsRow = this.db.get(sql `
|
|
455
|
+
SELECT
|
|
456
|
+
COUNT(*) as eventCount,
|
|
457
|
+
SUM(CASE WHEN event_type = 'tool_call' THEN 1 ELSE 0 END) as toolCallCount,
|
|
458
|
+
SUM(CASE WHEN severity IN ('error', 'critical') OR event_type = 'tool_error' THEN 1 ELSE 0 END) as errorCount,
|
|
459
|
+
COUNT(DISTINCT session_id) as uniqueSessions,
|
|
460
|
+
COUNT(DISTINCT agent_id) as uniqueAgents,
|
|
461
|
+
COALESCE(AVG(CASE WHEN event_type = 'tool_response' THEN json_extract(payload, '$.durationMs') ELSE NULL END), 0) as avgLatencyMs,
|
|
462
|
+
COALESCE(SUM(CASE WHEN event_type = 'cost_tracked' THEN json_extract(payload, '$.costUsd') ELSE 0 END), 0) as totalCostUsd
|
|
463
|
+
FROM events
|
|
464
|
+
WHERE timestamp >= ${params.from}
|
|
465
|
+
AND timestamp <= ${params.to}
|
|
466
|
+
${params.agentId ? sql `AND agent_id = ${params.agentId}` : sql ``}
|
|
467
|
+
`);
|
|
468
|
+
return {
|
|
469
|
+
buckets: bucketRows.map((row) => ({
|
|
470
|
+
timestamp: row.bucket,
|
|
471
|
+
eventCount: Number(row.eventCount),
|
|
472
|
+
toolCallCount: Number(row.toolCallCount),
|
|
473
|
+
errorCount: Number(row.errorCount),
|
|
474
|
+
avgLatencyMs: Number(row.avgLatencyMs),
|
|
475
|
+
totalCostUsd: Number(row.totalCostUsd),
|
|
476
|
+
uniqueSessions: Number(row.uniqueSessions),
|
|
477
|
+
})),
|
|
478
|
+
totals: {
|
|
479
|
+
eventCount: Number(totalsRow?.eventCount ?? 0),
|
|
480
|
+
toolCallCount: Number(totalsRow?.toolCallCount ?? 0),
|
|
481
|
+
errorCount: Number(totalsRow?.errorCount ?? 0),
|
|
482
|
+
avgLatencyMs: Number(totalsRow?.avgLatencyMs ?? 0),
|
|
483
|
+
totalCostUsd: Number(totalsRow?.totalCostUsd ?? 0),
|
|
484
|
+
uniqueSessions: Number(totalsRow?.uniqueSessions ?? 0),
|
|
485
|
+
uniqueAgents: Number(totalsRow?.uniqueAgents ?? 0),
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// ─── Alert Rules ───────────────────────────────────────────
|
|
490
|
+
async createAlertRule(rule) {
|
|
491
|
+
this.db
|
|
492
|
+
.insert(alertRules)
|
|
493
|
+
.values({
|
|
494
|
+
id: rule.id,
|
|
495
|
+
name: rule.name,
|
|
496
|
+
enabled: rule.enabled,
|
|
497
|
+
condition: rule.condition,
|
|
498
|
+
threshold: rule.threshold,
|
|
499
|
+
windowMinutes: rule.windowMinutes,
|
|
500
|
+
scope: JSON.stringify(rule.scope),
|
|
501
|
+
notifyChannels: JSON.stringify(rule.notifyChannels),
|
|
502
|
+
createdAt: rule.createdAt,
|
|
503
|
+
updatedAt: rule.updatedAt,
|
|
504
|
+
})
|
|
505
|
+
.run();
|
|
506
|
+
}
|
|
507
|
+
// HIGH 8: Check affected row count and throw NotFoundError when zero
|
|
508
|
+
async updateAlertRule(id, updates) {
|
|
509
|
+
const setValues = {};
|
|
510
|
+
if (updates.name !== undefined)
|
|
511
|
+
setValues.name = updates.name;
|
|
512
|
+
if (updates.enabled !== undefined)
|
|
513
|
+
setValues.enabled = updates.enabled;
|
|
514
|
+
if (updates.condition !== undefined)
|
|
515
|
+
setValues.condition = updates.condition;
|
|
516
|
+
if (updates.threshold !== undefined)
|
|
517
|
+
setValues.threshold = updates.threshold;
|
|
518
|
+
if (updates.windowMinutes !== undefined)
|
|
519
|
+
setValues.windowMinutes = updates.windowMinutes;
|
|
520
|
+
if (updates.scope !== undefined)
|
|
521
|
+
setValues.scope = JSON.stringify(updates.scope);
|
|
522
|
+
if (updates.notifyChannels !== undefined)
|
|
523
|
+
setValues.notifyChannels = JSON.stringify(updates.notifyChannels);
|
|
524
|
+
if (updates.updatedAt !== undefined)
|
|
525
|
+
setValues.updatedAt = updates.updatedAt;
|
|
526
|
+
if (Object.keys(setValues).length === 0) {
|
|
527
|
+
// Even with no actual updates, verify the rule exists
|
|
528
|
+
const existing = this.db
|
|
529
|
+
.select({ id: alertRules.id })
|
|
530
|
+
.from(alertRules)
|
|
531
|
+
.where(eq(alertRules.id, id))
|
|
532
|
+
.get();
|
|
533
|
+
if (!existing) {
|
|
534
|
+
throw new NotFoundError(`Alert rule not found: ${id}`);
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const result = this.db
|
|
539
|
+
.update(alertRules)
|
|
540
|
+
.set(setValues)
|
|
541
|
+
.where(eq(alertRules.id, id))
|
|
542
|
+
.run();
|
|
543
|
+
if (result.changes === 0) {
|
|
544
|
+
throw new NotFoundError(`Alert rule not found: ${id}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async deleteAlertRule(id) {
|
|
548
|
+
const result = this.db.delete(alertRules).where(eq(alertRules.id, id)).run();
|
|
549
|
+
if (result.changes === 0) {
|
|
550
|
+
throw new NotFoundError(`Alert rule not found: ${id}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async listAlertRules() {
|
|
554
|
+
const rows = this.db.select().from(alertRules).all();
|
|
555
|
+
return rows.map(this._mapAlertRuleRow);
|
|
556
|
+
}
|
|
557
|
+
async getAlertRule(id) {
|
|
558
|
+
const row = this.db
|
|
559
|
+
.select()
|
|
560
|
+
.from(alertRules)
|
|
561
|
+
.where(eq(alertRules.id, id))
|
|
562
|
+
.get();
|
|
563
|
+
return row ? this._mapAlertRuleRow(row) : null;
|
|
564
|
+
}
|
|
565
|
+
// ─── Maintenance ───────────────────────────────────────────
|
|
566
|
+
async applyRetention(olderThan) {
|
|
567
|
+
// Count events to be deleted
|
|
568
|
+
const countResult = this.db
|
|
569
|
+
.select({ count: drizzleCount() })
|
|
570
|
+
.from(events)
|
|
571
|
+
.where(lte(events.timestamp, olderThan))
|
|
572
|
+
.get();
|
|
573
|
+
const deletedCount = countResult?.count ?? 0;
|
|
574
|
+
if (deletedCount === 0)
|
|
575
|
+
return { deletedCount: 0 };
|
|
576
|
+
this.db.transaction((tx) => {
|
|
577
|
+
// Delete old events
|
|
578
|
+
tx.delete(events).where(lte(events.timestamp, olderThan)).run();
|
|
579
|
+
// Clean up sessions with no remaining events
|
|
580
|
+
tx.run(sql `
|
|
581
|
+
DELETE FROM sessions
|
|
582
|
+
WHERE id NOT IN (SELECT DISTINCT session_id FROM events)
|
|
583
|
+
`);
|
|
584
|
+
});
|
|
585
|
+
return { deletedCount };
|
|
586
|
+
}
|
|
587
|
+
async getStats() {
|
|
588
|
+
const eventCount = this.db
|
|
589
|
+
.select({ count: drizzleCount() })
|
|
590
|
+
.from(events)
|
|
591
|
+
.get()?.count ?? 0;
|
|
592
|
+
const sessionCount = this.db
|
|
593
|
+
.select({ count: drizzleCount() })
|
|
594
|
+
.from(sessions)
|
|
595
|
+
.get()?.count ?? 0;
|
|
596
|
+
const agentCount = this.db
|
|
597
|
+
.select({ count: drizzleCount() })
|
|
598
|
+
.from(agents)
|
|
599
|
+
.get()?.count ?? 0;
|
|
600
|
+
const oldest = this.db
|
|
601
|
+
.select({ timestamp: events.timestamp })
|
|
602
|
+
.from(events)
|
|
603
|
+
.orderBy(asc(events.timestamp))
|
|
604
|
+
.limit(1)
|
|
605
|
+
.get();
|
|
606
|
+
const newest = this.db
|
|
607
|
+
.select({ timestamp: events.timestamp })
|
|
608
|
+
.from(events)
|
|
609
|
+
.orderBy(desc(events.timestamp))
|
|
610
|
+
.limit(1)
|
|
611
|
+
.get();
|
|
612
|
+
// SQLite page_count * page_size for storage size
|
|
613
|
+
const pageCount = this.db.get(sql `PRAGMA page_count`)
|
|
614
|
+
?.page_count ?? 0;
|
|
615
|
+
const pageSize = this.db.get(sql `PRAGMA page_size`)
|
|
616
|
+
?.page_size ?? 0;
|
|
617
|
+
return {
|
|
618
|
+
totalEvents: eventCount,
|
|
619
|
+
totalSessions: sessionCount,
|
|
620
|
+
totalAgents: agentCount,
|
|
621
|
+
oldestEvent: oldest?.timestamp,
|
|
622
|
+
newestEvent: newest?.timestamp,
|
|
623
|
+
storageSizeBytes: pageCount * pageSize,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// ─── Private Helpers ───────────────────────────────────────
|
|
627
|
+
_buildEventConditions(query) {
|
|
628
|
+
const conditions = [];
|
|
629
|
+
if (query.sessionId) {
|
|
630
|
+
conditions.push(eq(events.sessionId, query.sessionId));
|
|
631
|
+
}
|
|
632
|
+
if (query.agentId) {
|
|
633
|
+
conditions.push(eq(events.agentId, query.agentId));
|
|
634
|
+
}
|
|
635
|
+
if (query.eventType) {
|
|
636
|
+
if (Array.isArray(query.eventType)) {
|
|
637
|
+
conditions.push(inArray(events.eventType, query.eventType));
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
conditions.push(eq(events.eventType, query.eventType));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (query.severity) {
|
|
644
|
+
if (Array.isArray(query.severity)) {
|
|
645
|
+
conditions.push(inArray(events.severity, query.severity));
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
conditions.push(eq(events.severity, query.severity));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (query.from) {
|
|
652
|
+
conditions.push(gte(events.timestamp, query.from));
|
|
653
|
+
}
|
|
654
|
+
if (query.to) {
|
|
655
|
+
conditions.push(lte(events.timestamp, query.to));
|
|
656
|
+
}
|
|
657
|
+
if (query.search) {
|
|
658
|
+
conditions.push(like(events.payload, `%${query.search}%`));
|
|
659
|
+
}
|
|
660
|
+
return conditions;
|
|
661
|
+
}
|
|
662
|
+
// HIGH 4: Use json_each() for exact tag matching with OR semantics
|
|
663
|
+
_buildSessionConditions(query) {
|
|
664
|
+
const conditions = [];
|
|
665
|
+
if (query.agentId) {
|
|
666
|
+
conditions.push(eq(sessions.agentId, query.agentId));
|
|
667
|
+
}
|
|
668
|
+
if (query.status) {
|
|
669
|
+
if (Array.isArray(query.status)) {
|
|
670
|
+
if (query.status.length === 1) {
|
|
671
|
+
conditions.push(eq(sessions.status, query.status[0]));
|
|
672
|
+
}
|
|
673
|
+
else if (query.status.length > 1) {
|
|
674
|
+
conditions.push(inArray(sessions.status, query.status));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
conditions.push(eq(sessions.status, query.status));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (query.from) {
|
|
682
|
+
conditions.push(gte(sessions.startedAt, query.from));
|
|
683
|
+
}
|
|
684
|
+
if (query.to) {
|
|
685
|
+
conditions.push(lte(sessions.startedAt, query.to));
|
|
686
|
+
}
|
|
687
|
+
if (query.tags && query.tags.length > 0) {
|
|
688
|
+
// Use json_each for exact tag matching with OR semantics
|
|
689
|
+
// A session matches if it contains ANY of the specified tags
|
|
690
|
+
const tagPlaceholders = query.tags.map((tag) => sql `${tag}`);
|
|
691
|
+
conditions.push(sql `EXISTS (
|
|
692
|
+
SELECT 1 FROM json_each(${sessions.tags}) AS je
|
|
693
|
+
WHERE je.value IN (${sql.join(tagPlaceholders, sql `, `)})
|
|
694
|
+
)`);
|
|
695
|
+
}
|
|
696
|
+
return conditions;
|
|
697
|
+
}
|
|
698
|
+
// HIGH 7: Use safeJsonParse everywhere
|
|
699
|
+
_mapEventRow(row) {
|
|
700
|
+
return {
|
|
701
|
+
id: row.id,
|
|
702
|
+
timestamp: row.timestamp,
|
|
703
|
+
sessionId: row.sessionId,
|
|
704
|
+
agentId: row.agentId,
|
|
705
|
+
eventType: row.eventType,
|
|
706
|
+
severity: row.severity,
|
|
707
|
+
payload: safeJsonParse(row.payload, {}),
|
|
708
|
+
metadata: safeJsonParse(row.metadata, {}),
|
|
709
|
+
prevHash: row.prevHash,
|
|
710
|
+
hash: row.hash,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
_mapSessionRow(row) {
|
|
714
|
+
return {
|
|
715
|
+
id: row.id,
|
|
716
|
+
agentId: row.agentId,
|
|
717
|
+
agentName: row.agentName ?? undefined,
|
|
718
|
+
startedAt: row.startedAt,
|
|
719
|
+
endedAt: row.endedAt ?? undefined,
|
|
720
|
+
status: row.status,
|
|
721
|
+
eventCount: row.eventCount,
|
|
722
|
+
toolCallCount: row.toolCallCount,
|
|
723
|
+
errorCount: row.errorCount,
|
|
724
|
+
totalCostUsd: row.totalCostUsd,
|
|
725
|
+
tags: safeJsonParse(row.tags, []),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
_mapAgentRow(row) {
|
|
729
|
+
return {
|
|
730
|
+
id: row.id,
|
|
731
|
+
name: row.name,
|
|
732
|
+
description: row.description ?? undefined,
|
|
733
|
+
firstSeenAt: row.firstSeenAt,
|
|
734
|
+
lastSeenAt: row.lastSeenAt,
|
|
735
|
+
sessionCount: row.sessionCount,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
_mapAlertRuleRow(row) {
|
|
739
|
+
return {
|
|
740
|
+
id: row.id,
|
|
741
|
+
name: row.name,
|
|
742
|
+
enabled: row.enabled,
|
|
743
|
+
condition: row.condition,
|
|
744
|
+
threshold: row.threshold,
|
|
745
|
+
windowMinutes: row.windowMinutes,
|
|
746
|
+
scope: safeJsonParse(row.scope, {}),
|
|
747
|
+
notifyChannels: safeJsonParse(row.notifyChannels, []),
|
|
748
|
+
createdAt: row.createdAt,
|
|
749
|
+
updatedAt: row.updatedAt,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
//# sourceMappingURL=sqlite-store.js.map
|