Skip to main content

agentsight_capture/sinks/
sqlite.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use 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}