1use crate::json::{parse_optional_value as parse_optional_json, parse_value as parse_json_value};
5use crate::model::{
6 AuditEventRow, LlmCallRow, NetworkTargetRow, ProcessNodeRow, ResourceSampleRow, TokenUsageRow,
7 ToolCallRow, ViewResult, ViewSink,
8};
9use rusqlite::{Connection, OpenFlags, params};
10use std::path::Path;
11
12pub struct SqliteStore {
13 conn: Connection,
14}
15
16impl SqliteStore {
17 pub fn open(path: impl AsRef<Path>) -> ViewResult<Self> {
18 let conn = Connection::open(path)?;
19 let mut store = Self { conn };
20 store.init()?;
21 Ok(store)
22 }
23
24 pub fn open_readonly(path: impl AsRef<Path>) -> ViewResult<Self> {
25 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
26 Ok(Self { conn })
27 }
28
29 fn has_column(&self, table: &str, column: &str) -> bool {
30 self.conn
31 .prepare(&format!("SELECT {column} FROM {table} LIMIT 0"))
32 .is_ok()
33 }
34
35 #[cfg(any(test, feature = "test-support"))]
36 pub fn connection(&self) -> &Connection {
37 &self.conn
38 }
39
40 fn init(&mut self) -> ViewResult<()> {
41 self.conn.pragma_update(None, "journal_mode", "WAL").ok();
42 self.conn
43 .busy_timeout(std::time::Duration::from_millis(500))
44 .ok();
45 self.conn.pragma_update(None, "foreign_keys", "ON")?;
46 self.conn.execute_batch(SCHEMA)?;
47 self.ensure_llm_call_columns()?;
48 Ok(())
49 }
50
51 fn ensure_llm_call_columns(&self) -> ViewResult<()> {
52 for (column, ty) in [
53 ("session_id", "TEXT"),
54 ("conversation_id", "TEXT"),
55 ("call_kind", "TEXT"),
56 ("status", "TEXT NOT NULL DEFAULT 'observed'"),
57 ("error_type", "TEXT"),
58 ("finish_reason", "TEXT"),
59 ] {
60 if !self.has_column("llm_calls", column) {
61 self.conn.execute(
62 &format!("ALTER TABLE llm_calls ADD COLUMN {column} {ty}"),
63 [],
64 )?;
65 }
66 }
67 Ok(())
68 }
69
70 fn upsert_network_target(&self, target: &NetworkTargetRow) -> ViewResult<()> {
71 let id = network_target_id(target);
72 self.conn.execute(
73 "INSERT INTO network_targets (
74 id, pid, comm, host, path, count, error_count, first_timestamp_ms, last_timestamp_ms
75 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
76 ON CONFLICT(id) DO UPDATE SET
77 count = count + excluded.count,
78 error_count = error_count + excluded.error_count,
79 first_timestamp_ms = MIN(first_timestamp_ms, excluded.first_timestamp_ms),
80 last_timestamp_ms = MAX(last_timestamp_ms, excluded.last_timestamp_ms)",
81 params![
82 id,
83 target.pid.map(|v| v as i64),
84 target.comm.as_deref(),
85 target.host.as_str(),
86 target.path.as_deref(),
87 target.count,
88 target.error_count,
89 target.first_timestamp_ms.map(|v| v as i64),
90 target.last_timestamp_ms.map(|v| v as i64),
91 ],
92 )?;
93 Ok(())
94 }
95
96 fn insert_resource_sample(&self, sample: &ResourceSampleRow) -> ViewResult<()> {
97 self.conn.execute(
98 "INSERT INTO resource_samples (timestamp_ms, pid, comm, cpu_percent, rss_mb)
99 VALUES (?1, ?2, ?3, ?4, ?5)",
100 params![
101 sample.timestamp_ms as i64,
102 sample.pid.map(|v| v as i64),
103 sample.comm.as_deref(),
104 sample.cpu_percent,
105 sample.rss_mb,
106 ],
107 )?;
108 Ok(())
109 }
110
111 fn insert_llm_call(&self, call: &LlmCallRow) -> ViewResult<()> {
112 self.conn.execute(
113 "INSERT OR REPLACE INTO llm_calls (
114 id, session_id, conversation_id, start_timestamp_ms, end_timestamp_ms,
115 pid, comm, provider, model, call_kind, status, error_type, finish_reason,
116 host, path, status_code, request_body_json, response_body_json,
117 view_source, confidence
118 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
119 params![
120 call.id,
121 call.session_id.as_deref(),
122 call.conversation_id.as_deref(),
123 call.start_timestamp_ms as i64,
124 call.end_timestamp_ms.map(|v| v as i64),
125 call.pid.map(|v| v as i64),
126 call.comm.as_deref(),
127 call.provider.as_deref(),
128 call.model.as_deref(),
129 call.call_kind.as_deref(),
130 call.status.as_str(),
131 call.error_type.as_deref(),
132 call.finish_reason.as_deref(),
133 call.host.as_deref(),
134 call.path.as_deref(),
135 call.status_code.map(|v| v as i64),
136 call.request.to_string(),
137 call.response.to_string(),
138 "live_view",
139 1.0f32,
140 ],
141 )?;
142 Ok(())
143 }
144
145 fn insert_token_usage(&self, token: &TokenUsageRow) -> ViewResult<()> {
146 self.conn.execute(
147 "INSERT OR REPLACE INTO token_usage (
148 id, llm_call_id, timestamp_ms, pid, comm, provider, model,
149 input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
150 total_tokens, source, view_source, confidence
151 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
152 params![
153 token.id,
154 token.llm_call_id,
155 token.timestamp_ms as i64,
156 token.pid.map(|v| v as i64),
157 token.comm.as_deref(),
158 token.provider.as_deref(),
159 token.model.as_deref(),
160 token.input_tokens,
161 token.output_tokens,
162 token.cache_creation_tokens,
163 token.cache_read_tokens,
164 token.total_tokens,
165 token.source,
166 token.view_source,
167 token.confidence.unwrap_or(1.0),
168 ],
169 )?;
170 Ok(())
171 }
172
173 fn insert_audit_event(&self, audit: &AuditEventRow) -> ViewResult<()> {
174 self.conn.execute(
175 "INSERT OR REPLACE INTO audit_events (
176 id, timestamp_ms, audit_type, pid, comm, subject,
177 action, target, status, summary, details_json
178 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
179 params![
180 audit.id,
181 audit.timestamp_ms as i64,
182 audit.audit_type,
183 audit.pid.map(|v| v as i64),
184 audit.comm.as_deref(),
185 audit.subject.as_deref(),
186 audit.action.as_deref(),
187 audit.target.as_deref(),
188 audit.status.as_deref(),
189 audit.summary.as_deref(),
190 audit.details.to_string(),
191 ],
192 )?;
193 Ok(())
194 }
195
196 fn upsert_process_node(&self, process: &ProcessNodeRow) -> ViewResult<()> {
197 self.conn.execute(
198 "INSERT INTO process_nodes (
199 id, pid, ppid, root_pid, start_timestamp_ms, end_timestamp_ms,
200 comm, command, argv_json, cwd, exit_code, status, view_source, confidence
201 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
202 ON CONFLICT(id) DO UPDATE SET
203 ppid = COALESCE(excluded.ppid, ppid),
204 root_pid = COALESCE(excluded.root_pid, root_pid),
205 start_timestamp_ms = CASE
206 WHEN start_timestamp_ms IS NULL THEN excluded.start_timestamp_ms
207 WHEN excluded.start_timestamp_ms IS NULL THEN start_timestamp_ms
208 ELSE MIN(start_timestamp_ms, excluded.start_timestamp_ms)
209 END,
210 end_timestamp_ms = CASE
211 WHEN end_timestamp_ms IS NULL THEN excluded.end_timestamp_ms
212 WHEN excluded.end_timestamp_ms IS NULL THEN end_timestamp_ms
213 ELSE MAX(end_timestamp_ms, excluded.end_timestamp_ms)
214 END,
215 comm = COALESCE(excluded.comm, comm),
216 command = COALESCE(excluded.command, command),
217 argv_json = CASE
218 WHEN argv_json = '[]' AND excluded.argv_json != '[]' THEN excluded.argv_json
219 ELSE argv_json
220 END,
221 cwd = COALESCE(excluded.cwd, cwd),
222 exit_code = COALESCE(excluded.exit_code, exit_code),
223 status = COALESCE(excluded.status, status),
224 confidence = MAX(COALESCE(confidence, 0), COALESCE(excluded.confidence, 0))",
225 params![
226 process.id,
227 process.pid as i64,
228 process.ppid.map(|v| v as i64),
229 process.root_pid.map(|v| v as i64),
230 process.start_timestamp_ms.map(|v| v as i64),
231 process.end_timestamp_ms.map(|v| v as i64),
232 process.comm.as_deref(),
233 process.command.as_deref(),
234 serde_json::to_string(&process.argv)?,
235 process.cwd.as_deref(),
236 process.exit_code.map(|v| v as i64),
237 process.status.as_deref(),
238 process.view_source,
239 process.confidence.unwrap_or(1.0),
240 ],
241 )?;
242 Ok(())
243 }
244
245 fn insert_tool_call(&self, tool: &ToolCallRow) -> ViewResult<()> {
246 self.conn.execute(
247 "INSERT OR REPLACE INTO tool_calls (
248 id, session_id, conversation_id, timestamp_ms, tool_name, tool_call_id,
249 start_timestamp_ms, end_timestamp_ms, duration_ms, status, input_json,
250 output_json, related_pid, related_event_id, view_source, confidence
251 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
252 params![
253 tool.id,
254 tool.session_id.as_deref(),
255 tool.conversation_id.as_deref(),
256 tool.timestamp_ms as i64,
257 tool.tool_name.as_deref(),
258 tool.tool_call_id.as_deref(),
259 tool.start_timestamp_ms.map(|v| v as i64),
260 tool.end_timestamp_ms.map(|v| v as i64),
261 tool.duration_ms.map(|v| v as i64),
262 tool.status.as_deref(),
263 tool.input.to_string(),
264 tool.output.to_string(),
265 tool.related_pid.map(|v| v as i64),
266 tool.related_event_id.as_deref(),
267 tool.view_source,
268 tool.confidence.unwrap_or(1.0),
269 ],
270 )?;
271 Ok(())
272 }
273
274 pub fn all_llm_call_rows(&self) -> ViewResult<Vec<LlmCallRow>> {
275 let optional_cols = [
276 ("session_id", "NULL AS session_id"),
277 ("conversation_id", "NULL AS conversation_id"),
278 ("call_kind", "NULL AS call_kind"),
279 ("status", "'observed' AS status"),
280 ("error_type", "NULL AS error_type"),
281 ("finish_reason", "NULL AS finish_reason"),
282 ];
283 let optional_select = optional_cols
284 .iter()
285 .map(|(column, fallback)| {
286 if self.has_column("llm_calls", column) {
287 (*column).to_string()
288 } else {
289 (*fallback).to_string()
290 }
291 })
292 .collect::<Vec<_>>()
293 .join(", ");
294 let mut stmt = self.conn.prepare(&format!(
295 "SELECT id, {optional_select}, start_timestamp_ms, end_timestamp_ms, pid, comm,
296 provider, model, host, path, status_code,
297 COALESCE(request_body_json, '{{}}'), COALESCE(response_body_json, '{{}}')
298 FROM llm_calls
299 ORDER BY start_timestamp_ms DESC"
300 ))?;
301 let rows = stmt.query_map([], read_llm_call_row)?;
302 collect_rows(rows)
303 }
304
305 pub fn token_usage_rows(&self) -> ViewResult<Vec<TokenUsageRow>> {
306 let has_view_source = self.has_column("token_usage", "view_source");
307 let extra_cols = if has_view_source {
308 ", view_source, confidence"
309 } else {
310 ""
311 };
312 let sql = format!(
313 "SELECT id, llm_call_id, timestamp_ms, pid, comm, provider, model,
314 input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
315 total_tokens, source{extra_cols}
316 FROM token_usage
317 ORDER BY timestamp_ms, id"
318 );
319 let mut stmt = self.conn.prepare(&sql)?;
320 let rows = stmt.query_map([], move |row| {
321 Ok(TokenUsageRow {
322 id: row.get(0)?,
323 llm_call_id: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
324 timestamp_ms: row.get::<_, i64>(2)? as u64,
325 pid: row.get::<_, Option<i64>>(3)?.map(|v| v as u32),
326 comm: row.get(4)?,
327 provider: row.get(5)?,
328 model: row.get(6)?,
329 input_tokens: row.get(7)?,
330 output_tokens: row.get(8)?,
331 cache_creation_tokens: row.get(9)?,
332 cache_read_tokens: row.get(10)?,
333 total_tokens: row.get(11)?,
334 source: row
335 .get::<_, Option<String>>(12)?
336 .unwrap_or_else(|| "unknown".to_string()),
337 view_source: if has_view_source {
338 row.get::<_, Option<String>>(13)?
339 .unwrap_or_else(|| "view".to_string())
340 } else {
341 "view".to_string()
342 },
343 confidence: if has_view_source { row.get(14)? } else { None },
344 })
345 })?;
346 collect_rows(rows)
347 }
348
349 pub fn tool_call_rows(&self) -> ViewResult<Vec<ToolCallRow>> {
350 let has_view_source = self.has_column("tool_calls", "view_source");
351 let extra_cols = if has_view_source {
352 ", view_source, confidence"
353 } else {
354 ""
355 };
356 let sql = format!(
357 "SELECT id, session_id, conversation_id, timestamp_ms, tool_name, tool_call_id,
358 start_timestamp_ms, end_timestamp_ms, duration_ms, status, input_json,
359 output_json, related_pid, related_event_id{extra_cols}
360 FROM tool_calls
361 ORDER BY timestamp_ms, id"
362 );
363 let mut stmt = self.conn.prepare(&sql)?;
364 let rows = stmt.query_map([], move |row| {
365 let input_json: Option<String> = row.get(10)?;
366 let output_json: Option<String> = row.get(11)?;
367 Ok(ToolCallRow {
368 id: row.get(0)?,
369 session_id: row.get(1)?,
370 conversation_id: row.get(2)?,
371 timestamp_ms: row.get::<_, i64>(3)? as u64,
372 tool_name: row.get(4)?,
373 tool_call_id: row.get(5)?,
374 start_timestamp_ms: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
375 end_timestamp_ms: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
376 duration_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
377 status: row.get(9)?,
378 input: parse_optional_json(input_json.as_deref()),
379 output: parse_optional_json(output_json.as_deref()),
380 related_pid: row.get::<_, Option<i64>>(12)?.map(|v| v as u32),
381 related_event_id: row.get(13)?,
382 view_source: if has_view_source {
383 row.get::<_, Option<String>>(14)?
384 .unwrap_or_else(|| "view".to_string())
385 } else {
386 "view".to_string()
387 },
388 confidence: if has_view_source { row.get(15)? } else { None },
389 })
390 })?;
391 collect_rows(rows)
392 }
393
394 pub fn resource_sample_rows(&self) -> ViewResult<Vec<ResourceSampleRow>> {
395 let mut stmt = self.conn.prepare(
396 "SELECT timestamp_ms, pid, comm, cpu_percent, rss_mb
397 FROM resource_samples
398 ORDER BY timestamp_ms",
399 )?;
400 let rows = stmt.query_map([], |row| {
401 Ok(ResourceSampleRow {
402 timestamp_ms: row.get::<_, i64>(0)? as u64,
403 pid: row.get::<_, Option<i64>>(1)?.map(|v| v as u32),
404 comm: row.get(2)?,
405 cpu_percent: row.get(3)?,
406 rss_mb: row.get(4)?,
407 })
408 })?;
409 collect_rows(rows)
410 }
411
412 pub fn network_target_rows(&self) -> ViewResult<Vec<NetworkTargetRow>> {
413 let mut stmt = self.conn.prepare(
414 "SELECT pid, comm, host, path, count, error_count, first_timestamp_ms, last_timestamp_ms
415 FROM network_targets
416 ORDER BY count DESC, host, path",
417 )?;
418 let rows = stmt.query_map([], |row| {
419 Ok(NetworkTargetRow {
420 pid: row.get::<_, Option<i64>>(0)?.map(|v| v as u32),
421 comm: row.get(1)?,
422 host: row.get(2)?,
423 path: row.get(3)?,
424 count: row.get(4)?,
425 error_count: row.get(5)?,
426 first_timestamp_ms: row.get::<_, Option<i64>>(6)?.map(|v| v as u64),
427 last_timestamp_ms: row.get::<_, Option<i64>>(7)?.map(|v| v as u64),
428 })
429 })?;
430 collect_rows(rows)
431 }
432
433 pub fn all_audit_event_rows(&self) -> ViewResult<Vec<AuditEventRow>> {
434 let mut stmt = self.conn.prepare(
435 "SELECT id, timestamp_ms, audit_type, pid, comm, subject, action,
436 target, status, summary, details_json
437 FROM audit_events
438 ORDER BY timestamp_ms, id",
439 )?;
440 let rows = stmt.query_map([], read_audit_event_row)?;
441 collect_rows(rows)
442 }
443
444 pub fn process_node_rows(&self) -> ViewResult<Vec<ProcessNodeRow>> {
445 let has_view_source = self.has_column("process_nodes", "view_source");
446 let extra_cols = if has_view_source {
447 ", view_source, confidence"
448 } else {
449 ""
450 };
451 let sql = format!(
452 "SELECT id, pid, ppid, root_pid, start_timestamp_ms, end_timestamp_ms,
453 comm, command, argv_json, cwd, exit_code, status{extra_cols}
454 FROM process_nodes
455 ORDER BY COALESCE(start_timestamp_ms, end_timestamp_ms, 0), pid, id"
456 );
457 let mut stmt = self.conn.prepare(&sql)?;
458 let rows = stmt.query_map([], move |row| {
459 let argv_json: String = row.get(8)?;
460 Ok(ProcessNodeRow {
461 id: row.get(0)?,
462 pid: row.get::<_, i64>(1)? as u32,
463 ppid: row.get::<_, Option<i64>>(2)?.map(|v| v as u32),
464 root_pid: row.get::<_, Option<i64>>(3)?.map(|v| v as u32),
465 start_timestamp_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
466 end_timestamp_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
467 comm: row.get(6)?,
468 command: row.get(7)?,
469 argv: serde_json::from_str(&argv_json).unwrap_or_default(),
470 cwd: row.get(9)?,
471 exit_code: row.get::<_, Option<i64>>(10)?.map(|v| v as i32),
472 status: row.get(11)?,
473 view_source: if has_view_source {
474 row.get::<_, Option<String>>(12)?
475 .unwrap_or_else(|| "view".to_string())
476 } else {
477 "view".to_string()
478 },
479 confidence: if has_view_source { row.get(13)? } else { None },
480 })
481 })?;
482 collect_rows(rows)
483 }
484}
485
486impl ViewSink for SqliteStore {
487 fn llm_call(&mut self, row: &LlmCallRow) -> ViewResult<()> {
488 self.insert_llm_call(row)
489 }
490
491 fn token_usage(&mut self, row: &TokenUsageRow) -> ViewResult<()> {
492 self.insert_token_usage(row)
493 }
494
495 fn audit_event(&mut self, row: &AuditEventRow) -> ViewResult<()> {
496 self.insert_audit_event(row)
497 }
498
499 fn process_node(&mut self, row: &ProcessNodeRow) -> ViewResult<()> {
500 self.upsert_process_node(row)
501 }
502
503 fn tool_call(&mut self, row: &ToolCallRow) -> ViewResult<()> {
504 self.insert_tool_call(row)
505 }
506
507 fn network_target(&mut self, row: &NetworkTargetRow) -> ViewResult<()> {
508 self.upsert_network_target(row)
509 }
510
511 fn resource_sample(&mut self, row: &ResourceSampleRow) -> ViewResult<()> {
512 self.insert_resource_sample(row)
513 }
514}
515
516fn read_llm_call_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<LlmCallRow> {
517 let request_json: String = row.get(16)?;
518 let response_json: String = row.get(17)?;
519 Ok(LlmCallRow {
520 id: row.get(0)?,
521 session_id: row.get(1)?,
522 conversation_id: row.get(2)?,
523 call_kind: row.get(3)?,
524 status: row
525 .get::<_, Option<String>>(4)?
526 .unwrap_or_else(|| "observed".to_string()),
527 error_type: row.get(5)?,
528 finish_reason: row.get(6)?,
529 start_timestamp_ms: row.get::<_, i64>(7)? as u64,
530 end_timestamp_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
531 pid: row.get::<_, Option<i64>>(9)?.map(|v| v as u32),
532 comm: row.get(10)?,
533 provider: row.get(11)?,
534 model: row.get(12)?,
535 host: row.get(13)?,
536 path: row.get(14)?,
537 status_code: row.get::<_, Option<i64>>(15)?.map(|v| v as u16),
538 input_tokens: 0,
539 output_tokens: 0,
540 total_tokens: 0,
541 request: parse_json_value(&request_json),
542 response: parse_json_value(&response_json),
543 })
544}
545
546fn read_audit_event_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuditEventRow> {
547 let details_json: String = row.get(10)?;
548 Ok(AuditEventRow {
549 id: row.get(0)?,
550 timestamp_ms: row.get::<_, i64>(1)? as u64,
551 audit_type: row.get(2)?,
552 pid: row.get::<_, Option<i64>>(3)?.map(|v| v as u32),
553 comm: row.get(4)?,
554 subject: row.get(5)?,
555 action: row.get(6)?,
556 target: row.get(7)?,
557 status: row.get(8)?,
558 summary: row.get(9)?,
559 details: parse_json_value(&details_json),
560 })
561}
562
563fn collect_rows<T, F>(rows: rusqlite::MappedRows<'_, F>) -> ViewResult<Vec<T>>
564where
565 F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
566{
567 let mut out = Vec::new();
568 for row in rows {
569 out.push(row?);
570 }
571 Ok(out)
572}
573
574fn network_target_id(target: &NetworkTargetRow) -> String {
575 let path = target.path.as_deref().unwrap_or_default();
576 format!(
577 "net:{}:{}:{}:{}:{}",
578 target.pid.unwrap_or_default(),
579 target.host.len(),
580 target.host,
581 path.len(),
582 path
583 )
584}
585
586const SCHEMA: &str = r#"
587CREATE TABLE IF NOT EXISTS network_targets (
588 id TEXT PRIMARY KEY,
589 pid INTEGER,
590 comm TEXT,
591 host TEXT NOT NULL,
592 path TEXT,
593 count INTEGER NOT NULL DEFAULT 0,
594 error_count INTEGER NOT NULL DEFAULT 0,
595 first_timestamp_ms INTEGER,
596 last_timestamp_ms INTEGER
597);
598
599CREATE INDEX IF NOT EXISTS idx_network_targets_host ON network_targets(host);
600CREATE INDEX IF NOT EXISTS idx_network_targets_pid ON network_targets(pid);
601
602CREATE TABLE IF NOT EXISTS llm_calls (
603 id TEXT PRIMARY KEY,
604 session_id TEXT,
605 conversation_id TEXT,
606 start_timestamp_ms INTEGER NOT NULL,
607 end_timestamp_ms INTEGER,
608 pid INTEGER,
609 comm TEXT,
610 provider TEXT,
611 model TEXT,
612 call_kind TEXT,
613 status TEXT NOT NULL DEFAULT 'observed',
614 error_type TEXT,
615 finish_reason TEXT,
616 host TEXT,
617 path TEXT,
618 status_code INTEGER,
619 request_body_json TEXT,
620 response_body_json TEXT,
621 view_source TEXT,
622 confidence REAL
623);
624
625CREATE INDEX IF NOT EXISTS idx_llm_calls_time ON llm_calls(start_timestamp_ms);
626
627CREATE TABLE IF NOT EXISTS token_usage (
628 id TEXT PRIMARY KEY,
629 llm_call_id TEXT,
630 timestamp_ms INTEGER NOT NULL,
631 pid INTEGER,
632 comm TEXT,
633 provider TEXT,
634 model TEXT,
635 input_tokens INTEGER DEFAULT 0,
636 output_tokens INTEGER DEFAULT 0,
637 cache_creation_tokens INTEGER DEFAULT 0,
638 cache_read_tokens INTEGER DEFAULT 0,
639 total_tokens INTEGER DEFAULT 0,
640 source TEXT NOT NULL,
641 view_source TEXT,
642 confidence REAL
643);
644
645CREATE INDEX IF NOT EXISTS idx_token_time ON token_usage(timestamp_ms);
646CREATE INDEX IF NOT EXISTS idx_token_model_time ON token_usage(model, timestamp_ms);
647CREATE INDEX IF NOT EXISTS idx_token_comm_time ON token_usage(comm, timestamp_ms);
648
649CREATE TABLE IF NOT EXISTS audit_events (
650 id TEXT PRIMARY KEY,
651 timestamp_ms INTEGER NOT NULL,
652 audit_type TEXT NOT NULL,
653 pid INTEGER,
654 comm TEXT,
655 subject TEXT,
656 action TEXT,
657 target TEXT,
658 status TEXT,
659 summary TEXT,
660 details_json TEXT NOT NULL DEFAULT '{}'
661);
662
663CREATE INDEX IF NOT EXISTS idx_audit_type_time ON audit_events(audit_type, timestamp_ms);
664CREATE INDEX IF NOT EXISTS idx_audit_pid_time ON audit_events(pid, timestamp_ms);
665
666CREATE TABLE IF NOT EXISTS process_nodes (
667 id TEXT PRIMARY KEY,
668 pid INTEGER NOT NULL,
669 ppid INTEGER,
670 root_pid INTEGER,
671 start_timestamp_ms INTEGER,
672 end_timestamp_ms INTEGER,
673 comm TEXT,
674 command TEXT,
675 argv_json TEXT NOT NULL DEFAULT '[]',
676 cwd TEXT,
677 exit_code INTEGER,
678 status TEXT,
679 view_source TEXT NOT NULL,
680 confidence REAL
681);
682
683CREATE INDEX IF NOT EXISTS idx_process_nodes_pid ON process_nodes(pid);
684CREATE INDEX IF NOT EXISTS idx_process_nodes_parent ON process_nodes(ppid);
685
686CREATE TABLE IF NOT EXISTS tool_calls (
687 id TEXT PRIMARY KEY,
688 session_id TEXT,
689 conversation_id TEXT,
690 timestamp_ms INTEGER NOT NULL,
691 start_timestamp_ms INTEGER,
692 end_timestamp_ms INTEGER,
693 duration_ms INTEGER,
694 tool_name TEXT,
695 tool_call_id TEXT,
696 status TEXT,
697 input_json TEXT,
698 output_json TEXT,
699 related_pid INTEGER,
700 related_event_id TEXT,
701 view_source TEXT NOT NULL,
702 confidence REAL
703);
704
705CREATE INDEX IF NOT EXISTS idx_tool_time ON tool_calls(timestamp_ms);
706CREATE INDEX IF NOT EXISTS idx_tool_name_time ON tool_calls(tool_name, timestamp_ms);
707
708CREATE TABLE IF NOT EXISTS resource_samples (
709 timestamp_ms INTEGER NOT NULL,
710 pid INTEGER,
711 comm TEXT,
712 cpu_percent REAL,
713 rss_mb INTEGER
714);
715
716"#;
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn open_migrates_legacy_llm_calls_schema() {
724 let temp = tempfile::tempdir().unwrap();
725 let db = temp.path().join("legacy.db");
726 {
727 let conn = Connection::open(&db).unwrap();
728 conn.execute_batch(
729 r#"
730 CREATE TABLE llm_calls (
731 id TEXT PRIMARY KEY,
732 start_timestamp_ms INTEGER NOT NULL,
733 end_timestamp_ms INTEGER,
734 pid INTEGER,
735 comm TEXT,
736 provider TEXT,
737 model TEXT,
738 host TEXT,
739 path TEXT,
740 status_code INTEGER,
741 request_body_json TEXT,
742 response_body_json TEXT,
743 view_source TEXT,
744 confidence REAL
745 );
746 INSERT INTO llm_calls (
747 id, start_timestamp_ms, end_timestamp_ms, pid, comm, provider, model,
748 host, path, status_code, request_body_json, response_body_json,
749 view_source, confidence
750 ) VALUES (
751 'legacy-call', 1000, 1100, 42, 'agent', 'openai', 'gpt-test',
752 'api.openai.com', '/v1/chat/completions', 200, '{}', '{}',
753 'view', 0.75
754 );
755 "#,
756 )
757 .unwrap();
758 }
759
760 let store = SqliteStore::open(&db).unwrap();
761 for column in [
762 "session_id",
763 "conversation_id",
764 "call_kind",
765 "status",
766 "error_type",
767 "finish_reason",
768 ] {
769 assert!(store.has_column("llm_calls", column), "{column}");
770 }
771
772 let calls = store.all_llm_call_rows().unwrap();
773 assert_eq!(calls.len(), 1);
774 assert_eq!(calls[0].id, "legacy-call");
775 assert_eq!(calls[0].status, "observed");
776 assert_eq!(calls[0].session_id, None);
777 assert_eq!(calls[0].conversation_id, None);
778 }
779}