Skip to main content

oxios_kernel/
agent_log_db.rs

1//! Agent history log — SQLite-backed query engine for past agent records.
2//!
3//! # Architecture
4//!
5//! Two-tier storage:
6//! - **Filesystem JSON** (`state/agents/<uuid>.json`): source of truth.
7//!   Human-readable, backup-friendly, rebuildable.
8//! - **SQLite** (`state/agent_log.db`): query index with indexes, FTS5.
9//!   Fast filtering, sorting, search, aggregation.
10//!
11//! SQLite DB is rebuildable from filesystem JSON at any time
12//! via [`AgentLogDb::reindex_all`].
13//!
14//! # Feature gate
15//!
16//! When `sqlite-memory` feature is disabled, all query operations
17//! fall back to filesystem-only scan mode. Degraded but functional.
18
19use std::path::Path;
20
21use anyhow::{Context, Result};
22use chrono::{DateTime, Utc};
23
24use crate::config::AgentLogConfig;
25use crate::state_store::StateStore;
26use crate::types::{AgentInfo, AgentStatus, ToolCallRecord};
27
28// ===========================================================================
29// Filter / Query Types
30// ===========================================================================
31
32/// Field to search against.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SearchField {
35    All,
36    Name,
37    Error,
38    ToolName,
39    ToolOutput,
40}
41
42impl SearchField {
43    #[allow(dead_code)]
44    fn as_str(&self) -> &'static str {
45        match self {
46            Self::All => "all",
47            Self::Name => "name",
48            Self::Error => "error",
49            Self::ToolName => "tool_name",
50            Self::ToolOutput => "tool_output",
51        }
52    }
53
54    /// Lenient best-effort parser with a default fallback (unknown → All).
55    /// Not a `FromStr` impl because it is infallible by design.
56    #[allow(clippy::should_implement_trait)]
57    pub fn from_str(s: &str) -> Self {
58        match s {
59            "name" => Self::Name,
60            "error" => Self::Error,
61            "tool_name" => Self::ToolName,
62            "tool_output" => Self::ToolOutput,
63            _ => Self::All,
64        }
65    }
66}
67
68/// Sort field.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum SortBy {
71    CreatedAt,
72    Cost,
73    Duration,
74    Tokens,
75    Name,
76}
77
78impl SortBy {
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            Self::CreatedAt => "created_at",
82            Self::Cost => "cost_usd",
83            Self::Duration => "duration_secs",
84            Self::Tokens => "tokens_total",
85            Self::Name => "name",
86        }
87    }
88
89    /// Lenient best-effort parser with a default fallback (unknown → CreatedAt).
90    #[allow(clippy::should_implement_trait)]
91    pub fn from_str(s: &str) -> Self {
92        match s {
93            "cost" => Self::Cost,
94            "duration" => Self::Duration,
95            "tokens" => Self::Tokens,
96            "name" => Self::Name,
97            _ => Self::CreatedAt,
98        }
99    }
100}
101
102/// Sort direction.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum SortDir {
105    Asc,
106    Desc,
107}
108
109impl SortDir {
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            Self::Asc => "ASC",
113            Self::Desc => "DESC",
114        }
115    }
116
117    /// Lenient best-effort parser with a default fallback (unknown → Desc).
118    #[allow(clippy::should_implement_trait)]
119    pub fn from_str(s: &str) -> Self {
120        match s {
121            "asc" => Self::Asc,
122            _ => Self::Desc,
123        }
124    }
125}
126
127/// Full filter specification for querying agent history.
128#[derive(Debug, Clone)]
129pub struct AgentListFilter {
130    pub q: Option<String>,
131    pub search_field: SearchField,
132    pub status: Option<AgentStatusFilter>,
133    pub session_id: Option<String>,
134    pub project_id: Option<String>,
135    pub model_id: Option<String>,
136    pub tool: Option<String>,
137    pub has_error: Option<bool>,
138    pub date_from: Option<DateTime<Utc>>,
139    pub date_to: Option<DateTime<Utc>>,
140    pub cost_min: Option<f64>,
141    pub cost_max: Option<f64>,
142    pub tokens_min: Option<u64>,
143    pub tokens_max: Option<u64>,
144    pub duration_min: Option<u64>,
145    pub duration_max: Option<u64>,
146    pub sort_by: SortBy,
147    pub sort_dir: SortDir,
148    pub page: u32,
149    pub per_page: u32,
150}
151
152impl Default for AgentListFilter {
153    fn default() -> Self {
154        Self {
155            q: None,
156            search_field: SearchField::All,
157            status: None,
158            session_id: None,
159            project_id: None,
160            model_id: None,
161            tool: None,
162            has_error: None,
163            date_from: None,
164            date_to: None,
165            cost_min: None,
166            cost_max: None,
167            tokens_min: None,
168            tokens_max: None,
169            duration_min: None,
170            duration_max: None,
171            sort_by: SortBy::CreatedAt,
172            sort_dir: SortDir::Desc,
173            page: 1,
174            per_page: 50,
175        }
176    }
177}
178
179/// Single status value that can be used to filter.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum AgentStatusFilter {
182    Running,
183    Completed,
184    Failed,
185    Stopped,
186    Starting,
187    Idle,
188}
189
190impl AgentStatusFilter {
191    pub fn as_sql(&self) -> &'static str {
192        match self {
193            Self::Running => "running",
194            Self::Completed => "completed",
195            Self::Failed => "failed",
196            Self::Stopped => "stopped",
197            Self::Starting => "starting",
198            Self::Idle => "idle",
199        }
200    }
201
202    /// Lenient best-effort parser; unknown values map to `None`.
203    #[allow(clippy::should_implement_trait)]
204    pub fn from_str(s: &str) -> Option<Self> {
205        match s {
206            "running" => Some(Self::Running),
207            "completed" => Some(Self::Completed),
208            "failed" => Some(Self::Failed),
209            "stopped" => Some(Self::Stopped),
210            "starting" => Some(Self::Starting),
211            "idle" => Some(Self::Idle),
212            _ => None,
213        }
214    }
215}
216
217// ===========================================================================
218// Filtered stats
219// ===========================================================================
220
221/// Aggregated stats computed from a query result set.
222#[derive(Debug, Clone, Default)]
223pub struct FilteredStats {
224    pub total_cost_usd: f64,
225    pub total_tokens: u64,
226    pub avg_duration_secs: f64,
227    pub count_running: u64,
228    pub count_completed: u64,
229    pub count_failed: u64,
230}
231
232/// Global aggregate stats (unfiltered).
233#[derive(Debug, Clone, Default)]
234pub struct AgentStats {
235    pub total_agents: u64,
236    pub running: u64,
237    pub completed: u64,
238    pub failed: u64,
239    pub total_cost_usd: f64,
240    pub total_tokens: u64,
241    pub total_duration_secs: u64,
242    pub avg_duration_secs: f64,
243    pub avg_cost_usd: f64,
244    pub total_sessions: u64,
245    pub oldest_agent_at: Option<DateTime<Utc>>,
246    pub newest_agent_at: Option<DateTime<Utc>>,
247}
248
249/// Query result with pagination and filtered stats.
250#[derive(Debug, Clone)]
251pub struct QueryResult {
252    pub items: Vec<AgentInfo>,
253    pub total: u64,
254    pub page: u32,
255    pub per_page: u32,
256    pub total_pages: u32,
257    pub stats: FilteredStats,
258}
259
260// ===========================================================================
261// Rebuild report
262// ===========================================================================
263
264#[derive(Debug, Clone, Default)]
265pub struct RebuildReport {
266    pub reindexed: u64,
267    pub orphaned: u64,
268    pub errors: u64,
269}
270// ===========================================================================
271// Cost aggregation (dollar-based spend views over agent_log_db)
272// ===========================================================================
273
274/// Aggregate spend summary for a time window.
275#[derive(Debug, Clone, Default, serde::Serialize)]
276pub struct CostSummary {
277    /// Total USD spent in the window.
278    pub total_cost_usd: f64,
279    /// Total tokens (input + output) consumed in the window.
280    pub total_tokens: u64,
281    /// Number of agent executions in the window.
282    pub agent_count: u64,
283}
284
285/// Per-model spend breakdown row.
286#[derive(Debug, Clone, serde::Serialize)]
287pub struct ModelCostRow {
288    /// Model ID (e.g. `anthropic/claude-sonnet-4`).
289    pub model_id: String,
290    /// USD spent on this model.
291    pub cost_usd: f64,
292    /// Tokens consumed on this model.
293    pub tokens: u64,
294    /// Agent executions using this model.
295    pub agent_count: u64,
296}
297
298/// Per-project spend breakdown row.
299#[derive(Debug, Clone, serde::Serialize)]
300pub struct ProjectCostRow {
301    /// Project ID (empty string = no project).
302    pub project_id: String,
303    /// USD spent in this project.
304    pub cost_usd: f64,
305    /// Tokens consumed in this project.
306    pub tokens: u64,
307    /// Agent executions in this project.
308    pub agent_count: u64,
309}
310
311/// Daily spend row for time-series charts.
312#[derive(Debug, Clone, serde::Serialize)]
313pub struct DailyCostRow {
314    /// Local date in `YYYY-MM-DD` format.
315    pub date: String,
316    /// USD spent that day.
317    pub cost_usd: f64,
318    /// Tokens consumed that day.
319    pub tokens: u64,
320    /// Agent executions that day.
321    pub agent_count: u64,
322}
323
324// ===========================================================================
325// AgentLogDb
326// ===========================================================================
327
328/// SQLite-backed agent history query engine.
329///
330/// Interior mutability via `parking_lot::Mutex` so all methods take `&self`,
331/// compatible with `Arc<AgentLogDb>` shared across tokio tasks.
332#[cfg(feature = "sqlite-memory")]
333pub struct AgentLogDb {
334    conn: parking_lot::Mutex<rusqlite::Connection>,
335}
336
337#[cfg(feature = "sqlite-memory")]
338impl AgentLogDb {
339    pub fn open(path: &Path) -> Result<Self> {
340        let conn = rusqlite::Connection::open(path)
341            .with_context(|| format!("Failed to open agent log database at {}", path.display()))?;
342        let db = Self {
343            conn: parking_lot::Mutex::new(conn),
344        };
345        db.migrate()?;
346        db.configure_wal()?;
347        Ok(db)
348    }
349
350    fn migrate(&self) -> Result<()> {
351        let conn = self.conn.lock();
352        conn.execute_batch(
353            "
354            CREATE TABLE IF NOT EXISTS agents (
355                id              TEXT PRIMARY KEY,
356                name            TEXT NOT NULL,
357                status          TEXT NOT NULL,
358                created_at      TEXT NOT NULL,
359                started_at      TEXT,
360                completed_at    TEXT,
361                session_id      TEXT,
362                project_id      TEXT,
363                model_id        TEXT NOT NULL DEFAULT '',
364                error           TEXT,
365                steps_completed INTEGER NOT NULL DEFAULT 0,
366                steps_total     INTEGER,
367                tokens_input    INTEGER NOT NULL DEFAULT 0,
368                tokens_output   INTEGER NOT NULL DEFAULT 0,
369                cost_usd        REAL NOT NULL DEFAULT 0.0,
370                duration_secs   INTEGER
371            );
372
373            CREATE TABLE IF NOT EXISTS agent_tool_calls (
374                id          INTEGER PRIMARY KEY AUTOINCREMENT,
375                agent_id    TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
376                seq         INTEGER NOT NULL,
377                tool_name   TEXT NOT NULL,
378                input       TEXT NOT NULL DEFAULT '',
379                output      TEXT NOT NULL DEFAULT '',
380                duration_ms INTEGER NOT NULL DEFAULT 0,
381                is_error    INTEGER NOT NULL DEFAULT 0,
382                timestamp   TEXT,
383                tool_call_id TEXT NOT NULL DEFAULT ''
384            );
385
386            CREATE INDEX IF NOT EXISTS idx_agents_status_created ON agents(status, created_at DESC);
387            CREATE INDEX IF NOT EXISTS idx_agents_session     ON agents(session_id);
388            CREATE INDEX IF NOT EXISTS idx_agents_project     ON agents(project_id);
389            CREATE INDEX IF NOT EXISTS idx_agents_model       ON agents(model_id);
390            CREATE INDEX IF NOT EXISTS idx_agents_cost        ON agents(cost_usd);
391            CREATE INDEX IF NOT EXISTS idx_agents_duration    ON agents(duration_secs);
392            CREATE INDEX IF NOT EXISTS idx_agents_name        ON agents(name);
393            CREATE INDEX IF NOT EXISTS idx_tool_calls_agent   ON agent_tool_calls(agent_id, seq);
394            CREATE INDEX IF NOT EXISTS idx_tool_calls_name    ON agent_tool_calls(tool_name);
395
396            CREATE TABLE IF NOT EXISTS agent_log_meta (
397                key   TEXT PRIMARY KEY,
398                value TEXT NOT NULL
399            );
400        ",
401        )
402        .context("Failed to run agent log SQLite migration")?;
403
404        // FTS5 virtual table (separately, catch errors if FTS5 not available)
405        let _ = conn.execute_batch(
406            "CREATE VIRTUAL TABLE IF NOT EXISTS agent_tool_calls_fts USING fts5(
407                tool_name, input, output,
408                content='agent_tool_calls',
409                content_rowid='id'
410            );",
411        );
412
413        Ok(())
414    }
415
416    fn configure_wal(&self) -> Result<()> {
417        let conn = self.conn.lock();
418        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
419            .context("Failed to configure WAL mode")
420    }
421
422    // ── Upsert ──────────────────────────────────────────────────────
423
424    pub fn upsert_agent(&self, info: &AgentInfo) -> Result<()> {
425        let mut conn = self.conn.lock();
426        let tx = conn
427            .transaction()
428            .context("Failed to begin agent upsert transaction")?;
429
430        let duration_secs = match (info.started_at, info.completed_at) {
431            (Some(start), Some(end)) => Some((end - start).num_seconds().max(0)),
432            _ => None,
433        };
434
435        tx.execute(
436            "INSERT INTO agents (id, name, status, created_at, started_at, completed_at,
437                 session_id, project_id, model_id, error,
438                 steps_completed, steps_total, tokens_input, tokens_output, cost_usd, duration_secs)
439             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
440             ON CONFLICT(id) DO UPDATE SET
441                 status=excluded.status,
442                 completed_at=excluded.completed_at,
443                 error=excluded.error,
444                 steps_completed=excluded.steps_completed,
445                 steps_total=excluded.steps_total,
446                 tokens_input=excluded.tokens_input,
447                 tokens_output=excluded.tokens_output,
448                 cost_usd=excluded.cost_usd,
449                 duration_secs=excluded.duration_secs",
450            rusqlite::params![
451                info.id.to_string(),
452                info.name,
453                info.status.to_string(),
454                info.created_at.to_rfc3339(),
455                info.started_at.map(|t| t.to_rfc3339()),
456                info.completed_at.map(|t| t.to_rfc3339()),
457                info.session_id,
458                info.project_id.map(|p| p.to_string()),
459                info.model_id,
460                info.error,
461                info.steps_completed as i64,
462                info.steps_total.map(|s| s as i64),
463                info.tokens_input as i64,
464                info.tokens_output as i64,
465                info.cost_usd,
466                duration_secs,
467            ],
468        )?;
469
470        // Replace tool calls
471        tx.execute(
472            "DELETE FROM agent_tool_calls WHERE agent_id = ?1",
473            rusqlite::params![info.id.to_string()],
474        )?;
475
476        for (i, tc) in info.tool_calls.iter().enumerate() {
477            tx.execute(
478                "INSERT INTO agent_tool_calls (agent_id, seq, tool_name, input, output,
479                     duration_ms, is_error, timestamp, tool_call_id)
480                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
481                rusqlite::params![
482                    info.id.to_string(),
483                    i as i64,
484                    tc.tool,
485                    tc.input,
486                    tc.output,
487                    tc.duration_ms as i64,
488                    tc.is_error as i64,
489                    tc.timestamp.map(|t| t.to_rfc3339()),
490                    tc.tool_call_id,
491                ],
492            )?;
493        }
494
495        tx.commit().context("Failed to commit agent upsert")?;
496
497        // Rebuild FTS
498        let _ = conn.execute_batch(
499            "INSERT INTO agent_tool_calls_fts(agent_tool_calls_fts) VALUES('rebuild');",
500        );
501
502        Ok(())
503    }
504
505    // ── Query ───────────────────────────────────────────────────────
506
507    pub fn query(&self, filter: &AgentListFilter) -> Result<QueryResult> {
508        let (where_clause, params) = self.build_where(filter);
509        let offset = ((filter.page.max(1) - 1) * filter.per_page) as i64;
510        let limit = filter.per_page.min(200) as i64;
511
512        let conn = self.conn.lock();
513
514        // Count
515        let count_sql = format!("SELECT COUNT(*) FROM agents WHERE {}", where_clause);
516        let total: u64 = conn
517            .query_row(
518                &count_sql,
519                rusqlite::params_from_iter(params.iter()),
520                |row| row.get(0),
521            )
522            .context("Failed to count agents")?;
523
524        // Data
525        let safe_sort_col = filter.sort_by.as_str();
526        let safe_sort_dir = filter.sort_dir.as_str();
527        let param_count = params.len();
528        let data_sql = format!(
529            "SELECT * FROM agents WHERE {} ORDER BY {} {} LIMIT ?{} OFFSET ?{}",
530            where_clause,
531            safe_sort_col,
532            safe_sort_dir,
533            param_count + 1,
534            param_count + 2,
535        );
536
537        let mut stmt = conn.prepare(&data_sql)?;
538        let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = params
539            .into_iter()
540            .map(|p| -> Box<dyn rusqlite::types::ToSql> { p })
541            .collect();
542        all_params.push(Box::new(limit));
543        all_params.push(Box::new(offset));
544
545        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
546            all_params.iter().map(|p| p.as_ref()).collect();
547
548        let items: Vec<AgentInfo> = stmt
549            .query_map(param_refs.as_slice(), Self::row_to_agent)?
550            .collect::<Result<Vec<_>, _>>()
551            .context("Failed to collect agent query results")?;
552
553        // Stats for filtered set
554        let stats = self.filtered_stats_inner(filter, &conn)?;
555
556        let total_pages = if total == 0 {
557            1
558        } else {
559            ((total as f64) / filter.per_page as f64).ceil() as u32
560        };
561
562        Ok(QueryResult {
563            items,
564            total,
565            page: filter.page,
566            per_page: filter.per_page,
567            total_pages,
568            stats,
569        })
570    }
571
572    pub fn stats(&self) -> Result<AgentStats> {
573        let conn = self.conn.lock();
574
575        let mut s = AgentStats::default();
576
577        let row = conn
578            .query_row(
579                "SELECT
580                    COUNT(*) as total,
581                    COALESCE(SUM(CASE WHEN status='running' THEN 1 ELSE 0 END), 0),
582                    COALESCE(SUM(CASE WHEN status IN ('completed','stopped') THEN 1 ELSE 0 END), 0),
583                    COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), 0),
584                    COALESCE(SUM(cost_usd), 0.0),
585                    COALESCE(SUM(tokens_input + tokens_output), 0),
586                    COALESCE(SUM(duration_secs), 0),
587                    COALESCE(AVG(duration_secs), 0.0),
588                    COALESCE(AVG(cost_usd), 0.0),
589                    COUNT(DISTINCT session_id),
590                    MIN(created_at),
591                    MAX(created_at)
592                 FROM agents",
593                [],
594                |row| {
595                    Ok((
596                        row.get::<_, i64>(0)? as u64,
597                        row.get::<_, i64>(1)? as u64,
598                        row.get::<_, i64>(2)? as u64,
599                        row.get::<_, i64>(3)? as u64,
600                        row.get::<_, f64>(4)?,
601                        row.get::<_, i64>(5)? as u64,
602                        row.get::<_, i64>(6)? as u64,
603                        row.get::<_, f64>(7)?,
604                        row.get::<_, f64>(8)?,
605                        row.get::<_, i64>(9)? as u64,
606                        row.get::<_, Option<String>>(10)?,
607                        row.get::<_, Option<String>>(11)?,
608                    ))
609                },
610            )
611            .context("Failed to query agent stats")?;
612
613        s.total_agents = row.0;
614        s.running = row.1;
615        s.completed = row.2;
616        s.failed = row.3;
617        s.total_cost_usd = row.4;
618        s.total_tokens = row.5;
619        s.total_duration_secs = row.6;
620        s.avg_duration_secs = if s.total_agents > 0 { row.7 } else { 0.0 };
621        s.avg_cost_usd = if s.total_agents > 0 { row.8 } else { 0.0 };
622        s.total_sessions = row.9;
623        s.oldest_agent_at = row
624            .10
625            .as_deref()
626            .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
627            .map(|dt| dt.with_timezone(&Utc));
628        s.newest_agent_at = row
629            .11
630            .as_deref()
631            .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
632            .map(|dt| dt.with_timezone(&Utc));
633
634        Ok(s)
635    }
636    // ── Cost aggregation (dollar-based spend views) ─────────────────
637
638    /// Aggregate spend for a time window (`since` = inclusive lower bound).
639    ///
640    /// When `since` is `None`, returns all-time totals.
641    pub fn cost_summary(&self, since: Option<DateTime<Utc>>) -> Result<CostSummary> {
642        let conn = self.conn.lock();
643        match since {
644            Some(ts) => {
645                let row = conn
646                    .query_row(
647                        "SELECT COALESCE(SUM(cost_usd), 0.0),
648                                COALESCE(SUM(tokens_input + tokens_output), 0),
649                                COUNT(*)
650                         FROM agents WHERE created_at >= ?1",
651                        rusqlite::params![ts.to_rfc3339()],
652                        |row| {
653                            Ok(CostSummary {
654                                total_cost_usd: row.get(0)?,
655                                total_tokens: row.get::<_, i64>(1)? as u64,
656                                agent_count: row.get::<_, i64>(2)? as u64,
657                            })
658                        },
659                    )
660                    .context("Failed to compute cost summary")?;
661                Ok(row)
662            }
663            None => {
664                let row = conn
665                    .query_row(
666                        "SELECT COALESCE(SUM(cost_usd), 0.0),
667                                COALESCE(SUM(tokens_input + tokens_output), 0),
668                                COUNT(*)
669                         FROM agents",
670                        [],
671                        |row| {
672                            Ok(CostSummary {
673                                total_cost_usd: row.get(0)?,
674                                total_tokens: row.get::<_, i64>(1)? as u64,
675                                agent_count: row.get::<_, i64>(2)? as u64,
676                            })
677                        },
678                    )
679                    .context("Failed to compute cost summary")?;
680                Ok(row)
681            }
682        }
683    }
684
685    /// Per-model spend breakdown for a time window.
686    pub fn cost_by_model(&self, since: Option<DateTime<Utc>>) -> Result<Vec<ModelCostRow>> {
687        let conn = self.conn.lock();
688        let since_ts = since.map(|t| t.to_rfc3339());
689        let mut stmt = if since_ts.is_some() {
690            conn.prepare(
691                "SELECT model_id,
692                        COALESCE(SUM(cost_usd), 0.0),
693                        COALESCE(SUM(tokens_input + tokens_output), 0),
694                        COUNT(*)
695                 FROM agents
696                 WHERE created_at >= ?1 AND model_id != ''
697                 GROUP BY model_id
698                 ORDER BY SUM(cost_usd) DESC",
699            )?
700        } else {
701            conn.prepare(
702                "SELECT model_id,
703                        COALESCE(SUM(cost_usd), 0.0),
704                        COALESCE(SUM(tokens_input + tokens_output), 0),
705                        COUNT(*)
706                 FROM agents
707                 WHERE model_id != ''
708                 GROUP BY model_id
709                 ORDER BY SUM(cost_usd) DESC",
710            )?
711        };
712
713        let map = |row: &rusqlite::Row| -> rusqlite::Result<ModelCostRow> {
714            Ok(ModelCostRow {
715                model_id: row.get(0)?,
716                cost_usd: row.get(1)?,
717                tokens: row.get::<_, i64>(2)? as u64,
718                agent_count: row.get::<_, i64>(3)? as u64,
719            })
720        };
721
722        let rows = match &since_ts {
723            Some(ts) => stmt.query_map(rusqlite::params![ts], map)?,
724            None => stmt.query_map([], map)?,
725        };
726        rows.collect::<Result<Vec<_>, _>>()
727            .context("Failed to collect cost-by-model rows")
728    }
729
730    /// Per-project spend breakdown for a time window.
731    pub fn cost_by_project(&self, since: Option<DateTime<Utc>>) -> Result<Vec<ProjectCostRow>> {
732        let conn = self.conn.lock();
733        let since_ts = since.map(|t| t.to_rfc3339());
734        let mut stmt = if since_ts.is_some() {
735            conn.prepare(
736                "SELECT COALESCE(project_id, ''),
737                        COALESCE(SUM(cost_usd), 0.0),
738                        COALESCE(SUM(tokens_input + tokens_output), 0),
739                        COUNT(*)
740                 FROM agents
741                 WHERE created_at >= ?1 AND project_id IS NOT NULL AND project_id != ''
742                 GROUP BY project_id
743                 ORDER BY SUM(cost_usd) DESC",
744            )?
745        } else {
746            conn.prepare(
747                "SELECT COALESCE(project_id, ''),
748                        COALESCE(SUM(cost_usd), 0.0),
749                        COALESCE(SUM(tokens_input + tokens_output), 0),
750                        COUNT(*)
751                 FROM agents
752                 WHERE project_id IS NOT NULL AND project_id != ''
753                 GROUP BY project_id
754                 ORDER BY SUM(cost_usd) DESC",
755            )?
756        };
757
758        let map = |row: &rusqlite::Row| -> rusqlite::Result<ProjectCostRow> {
759            Ok(ProjectCostRow {
760                project_id: row.get(0)?,
761                cost_usd: row.get(1)?,
762                tokens: row.get::<_, i64>(2)? as u64,
763                agent_count: row.get::<_, i64>(3)? as u64,
764            })
765        };
766
767        let rows = match &since_ts {
768            Some(ts) => stmt.query_map(rusqlite::params![ts], map)?,
769            None => stmt.query_map([], map)?,
770        };
771        rows.collect::<Result<Vec<_>, _>>()
772            .context("Failed to collect cost-by-project rows")
773    }
774
775    /// Daily spend time-series for the last `days` days (inclusive of today).
776    pub fn cost_daily(&self, days: u32) -> Result<Vec<DailyCostRow>> {
777        let conn = self.conn.lock();
778        let since = Utc::now() - chrono::Duration::days(days as i64);
779        let mut stmt = conn.prepare(
780            "SELECT date(created_at) AS d,
781                    COALESCE(SUM(cost_usd), 0.0),
782                    COALESCE(SUM(tokens_input + tokens_output), 0),
783                    COUNT(*)
784             FROM agents
785             WHERE created_at >= ?1
786             GROUP BY d
787             ORDER BY d ASC",
788        )?;
789
790        let rows = stmt.query_map(rusqlite::params![since.to_rfc3339()], |row| {
791            Ok(DailyCostRow {
792                date: row.get(0)?,
793                cost_usd: row.get(1)?,
794                tokens: row.get::<_, i64>(2)? as u64,
795                agent_count: row.get::<_, i64>(3)? as u64,
796            })
797        })?;
798
799        rows.collect::<Result<Vec<_>, _>>()
800            .context("Failed to collect daily cost rows")
801    }
802
803    pub fn get(&self, id: &str) -> Result<Option<AgentInfo>> {
804        let conn = self.conn.lock();
805        let mut stmt = conn
806            .prepare("SELECT * FROM agents WHERE id = ?1")
807            .context("Failed to prepare agent get statement")?;
808
809        let mut rows = stmt
810            .query_map(rusqlite::params![id], Self::row_to_agent)
811            .context("Failed to query agent by id")?;
812
813        match rows.next() {
814            Some(Ok(mut agent)) => {
815                agent.tool_calls = Self::get_tool_calls_inner(&conn, id)?;
816                Ok(Some(agent))
817            }
818            Some(Err(e)) => Err(e.into()),
819            None => Ok(None),
820        }
821    }
822
823    pub fn get_tool_calls(&self, agent_id: &str) -> Result<Vec<ToolCallRecord>> {
824        let conn = self.conn.lock();
825        Self::get_tool_calls_inner(&conn, agent_id)
826    }
827
828    pub fn delete(&self, id: &str) -> Result<bool> {
829        let conn = self.conn.lock();
830        let changes = conn
831            .execute("DELETE FROM agents WHERE id = ?1", rusqlite::params![id])
832            .context("Failed to delete agent")?;
833        Ok(changes > 0)
834    }
835
836    // ── Pruning ─────────────────────────────────────────────────────
837
838    pub fn prune(&self, config: &AgentLogConfig) -> Result<usize> {
839        let mut pruned = 0usize;
840
841        if config.ttl_hours > 0 || config.max_entries > 0 {
842            let conn = self.conn.lock();
843
844            // 1. TTL-based
845            if config.ttl_hours > 0 {
846                let cutoff = Utc::now() - chrono::Duration::hours(config.ttl_hours as i64);
847                let cutoff_str = cutoff.to_rfc3339();
848                let deleted = conn
849                    .execute(
850                        "DELETE FROM agents WHERE created_at < ?1",
851                        rusqlite::params![cutoff_str],
852                    )
853                    .context("Failed to prune agents by TTL")?;
854                pruned += deleted;
855            }
856
857            // 2. Count-based
858            if config.max_entries > 0 {
859                let count: i64 = conn
860                    .query_row("SELECT COUNT(*) FROM agents", [], |row| row.get(0))
861                    .context("Failed to count agents for pruning")?;
862
863                if count > config.max_entries as i64 {
864                    let excess = count - config.max_entries as i64;
865                    let to_delete = excess.min(config.prune_batch_size as i64);
866                    let deleted = conn
867                        .execute(
868                            "DELETE FROM agents WHERE id IN (
869                            SELECT id FROM agents ORDER BY created_at ASC LIMIT ?1
870                        )",
871                            rusqlite::params![to_delete],
872                        )
873                        .context("Failed to prune agents by count")?;
874                    // Report the rows actually deleted, not the estimate:
875                    // FK CASCADE or a concurrent prune can make the real
876                    // count differ from `to_delete`. (state-area F7.)
877                    pruned += deleted;
878                }
879            }
880        }
881
882        if pruned > 0 {
883            tracing::info!(pruned = pruned, "Agent log SQLite pruning completed");
884        }
885
886        Ok(pruned)
887    }
888
889    // ── Recovery ────────────────────────────────────────────────────
890
891    pub async fn reindex_all(&self, state_store: &StateStore) -> Result<RebuildReport> {
892        let agent_names = state_store
893            .list_category("agents")
894            .await
895            .unwrap_or_default();
896        let mut report = RebuildReport::default();
897
898        {
899            let conn = self.conn.lock();
900            conn.execute_batch("DELETE FROM agent_tool_calls; DELETE FROM agents;")
901                .context("Failed to clear agent tables for reindex")?;
902        }
903
904        for name in &agent_names {
905            match state_store.load_json::<AgentInfo>("agents", name).await {
906                Ok(Some(agent)) => {
907                    if let Err(e) = self.upsert_agent(&agent) {
908                        tracing::warn!(agent_id = %name, error = %e, "Failed to reindex agent");
909                        report.errors += 1;
910                    } else {
911                        report.reindexed += 1;
912                    }
913                }
914                _ => {
915                    report.errors += 1;
916                }
917            }
918        }
919
920        Ok(report)
921    }
922
923    // ── Internal Helpers ────────────────────────────────────────────
924
925    fn row_to_agent(row: &rusqlite::Row) -> rusqlite::Result<AgentInfo> {
926        let id_str: String = row.get("id")?;
927        let project_id_str: Option<String> = row.get("project_id")?;
928        let created_at_str: String = row.get("created_at")?;
929        let started_at_str: Option<String> = row.get("started_at")?;
930        let completed_at_str: Option<String> = row.get("completed_at")?;
931        Ok(AgentInfo {
932            id: uuid::Uuid::parse_str(&id_str).unwrap_or_else(|e| {
933                tracing::error!(agent_id = %id_str, error = %e, "Corrupt agent id in DB, substituting Nil");
934                uuid::Uuid::nil()
935            }),
936            name: row.get("name")?,
937            status: Self::parse_status(&row.get::<_, String>("status")?),
938            created_at: DateTime::parse_from_rfc3339(&created_at_str)
939                .map(|dt| dt.with_timezone(&Utc))
940                .unwrap_or_else(|e| {
941                    tracing::error!(
942                        agent_id = %id_str,
943                        created_at = %created_at_str,
944                        error = %e,
945                        "Corrupt created_at in DB, substituting Utc::now()"
946                    );
947                    Utc::now()
948                }),
949            project_id: project_id_str.and_then(|s| uuid::Uuid::parse_str(&s).ok()),
950            started_at: started_at_str
951                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
952                .map(|dt| dt.with_timezone(&Utc)),
953            completed_at: completed_at_str
954                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
955                .map(|dt| dt.with_timezone(&Utc)),
956            error: row.get("error")?,
957            steps_completed: row.get::<_, i64>("steps_completed")? as usize,
958            steps_total: row
959                .get::<_, Option<i64>>("steps_total")?
960                .map(|v| v as usize),
961            tool_calls: vec![],
962            tokens_input: row.get::<_, i64>("tokens_input")? as u64,
963            tokens_output: row.get::<_, i64>("tokens_output")? as u64,
964            cost_usd: row.get("cost_usd")?,
965            model_id: row.get("model_id")?,
966            session_id: row.get("session_id")?,
967        })
968    }
969
970    fn get_tool_calls_inner(
971        conn: &rusqlite::Connection,
972        agent_id: &str,
973    ) -> Result<Vec<ToolCallRecord>> {
974        let mut stmt = conn
975            .prepare(
976                "SELECT tool_name, input, output, duration_ms, is_error, timestamp, tool_call_id
977                 FROM agent_tool_calls WHERE agent_id = ?1 ORDER BY seq",
978            )
979            .context("Failed to prepare tool calls statement")?;
980
981        let calls = stmt
982            .query_map(rusqlite::params![agent_id], |row| {
983                let ts: Option<String> = row.get(5)?;
984                Ok(ToolCallRecord {
985                    tool: row.get(0)?,
986                    input: row.get(1)?,
987                    output: row.get(2)?,
988                    duration_ms: row.get::<_, i64>(3)? as u64,
989                    is_error: row.get::<_, i64>(4)? != 0,
990                    tool_call_id: row.get(6)?,
991                    timestamp: ts
992                        .and_then(|s| DateTime::parse_from_rfc3339(&s as &str).ok())
993                        .map(|dt| dt.with_timezone(&Utc)),
994                })
995            })
996            .context("Failed to query tool calls")?
997            .collect::<Result<Vec<_>, _>>()
998            .context("Failed to collect tool calls")?;
999
1000        Ok(calls)
1001    }
1002
1003    fn parse_status(s: &str) -> AgentStatus {
1004        match s {
1005            "starting" => AgentStatus::Starting,
1006            "running" => AgentStatus::Running,
1007            "idle" => AgentStatus::Idle,
1008            "stopped" => AgentStatus::Stopped,
1009            "failed" => AgentStatus::Failed,
1010            "completed" => AgentStatus::Completed,
1011            _ => AgentStatus::Idle,
1012        }
1013    }
1014
1015    /// Build WHERE clause and params from the filter.
1016    fn build_where(
1017        &self,
1018        filter: &AgentListFilter,
1019    ) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
1020        let mut conditions: Vec<String> = Vec::new();
1021        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1022
1023        // Status filter
1024        if let Some(status) = filter.status {
1025            let idx = params.len() + 1;
1026            conditions.push(format!("status = ?{}", idx));
1027            params.push(Box::new(status.as_sql().to_string()));
1028        }
1029
1030        // Date range
1031        if let Some(from) = filter.date_from {
1032            let idx = params.len() + 1;
1033            conditions.push(format!("created_at >= ?{}", idx));
1034            params.push(Box::new(from.to_rfc3339()));
1035        }
1036        if let Some(to) = filter.date_to {
1037            let idx = params.len() + 1;
1038            conditions.push(format!("created_at <= ?{}", idx));
1039            params.push(Box::new(to.to_rfc3339()));
1040        }
1041
1042        // Session / project
1043        if let Some(ref sid) = filter.session_id {
1044            let idx = params.len() + 1;
1045            conditions.push(format!("session_id = ?{}", idx));
1046            params.push(Box::new(sid.clone()));
1047        }
1048        if let Some(ref pid) = filter.project_id {
1049            let idx = params.len() + 1;
1050            conditions.push(format!("project_id = ?{}", idx));
1051            params.push(Box::new(pid.clone()));
1052        }
1053
1054        // Model (substring)
1055        if let Some(ref model) = filter.model_id {
1056            let idx = params.len() + 1;
1057            conditions.push(format!("model_id LIKE ?{}", idx));
1058            params.push(Box::new(format!("%{}%", model)));
1059        }
1060
1061        // Cost range
1062        if let Some(min) = filter.cost_min {
1063            let idx = params.len() + 1;
1064            conditions.push(format!("cost_usd >= ?{}", idx));
1065            params.push(Box::new(min));
1066        }
1067        if let Some(max) = filter.cost_max {
1068            let idx = params.len() + 1;
1069            conditions.push(format!("cost_usd <= ?{}", idx));
1070            params.push(Box::new(max));
1071        }
1072
1073        // Tokens range
1074        if filter.tokens_min.is_some() || filter.tokens_max.is_some() {
1075            let total_expr = "(tokens_input + tokens_output)";
1076            if let Some(min) = filter.tokens_min {
1077                let idx = params.len() + 1;
1078                conditions.push(format!("{} >= ?{}", total_expr, idx));
1079                params.push(Box::new(min as i64));
1080            }
1081            if let Some(max) = filter.tokens_max {
1082                let idx = params.len() + 1;
1083                conditions.push(format!("{} <= ?{}", total_expr, idx));
1084                params.push(Box::new(max as i64));
1085            }
1086        }
1087
1088        // Duration range
1089        if let Some(min) = filter.duration_min {
1090            let idx = params.len() + 1;
1091            conditions.push(format!("duration_secs >= ?{}", idx));
1092            params.push(Box::new(min as i64));
1093        }
1094        if let Some(max) = filter.duration_max {
1095            let idx = params.len() + 1;
1096            conditions.push(format!("duration_secs <= ?{}", idx));
1097            params.push(Box::new(max as i64));
1098        }
1099
1100        // Error filter
1101        if let Some(has_err) = filter.has_error {
1102            if has_err {
1103                conditions.push("error IS NOT NULL AND error != ''".to_string());
1104            } else {
1105                conditions.push("(error IS NULL OR error = '')".to_string());
1106            }
1107        }
1108
1109        // Tool filter (JOIN subquery)
1110        if let Some(ref tool) = filter.tool {
1111            let idx = params.len() + 1;
1112            conditions.push(format!(
1113                "id IN (SELECT DISTINCT agent_id FROM agent_tool_calls WHERE tool_name LIKE ?{})",
1114                idx
1115            ));
1116            params.push(Box::new(format!("%{}%", tool)));
1117        }
1118
1119        // Full-text search via FTS5
1120        let has_fts = matches!(
1121            filter.search_field,
1122            SearchField::All | SearchField::ToolName | SearchField::ToolOutput
1123        ) && filter.q.is_some();
1124
1125        if has_fts {
1126            if let Some(ref q) = filter.q {
1127                // Treat user input as an FTS5 *phrase* (double-quoted
1128                // string) so FTS5 query syntax — `*` wildcards, `:`
1129                // column qualifiers, `AND`/`OR`/`NEAR`, parentheses —
1130                // is treated as literal text, not operators. Internal
1131                // double-quotes are escaped by doubling (FTS5 rule).
1132                // Bound as a parameter so it never reaches the SQL parser
1133                // as code. (state-area F8.)
1134                let phrase = format!("\"{}\"", q.replace('"', "\"\""));
1135                let idx = params.len() + 1;
1136                conditions.push(format!(
1137                    "id IN (SELECT DISTINCT agent_id FROM agent_tool_calls_fts WHERE agent_tool_calls_fts MATCH ?{idx})"
1138                ));
1139                params.push(Box::new(phrase));
1140            }
1141        } else if let Some(ref q) = filter.q {
1142            match filter.search_field {
1143                SearchField::Name => {
1144                    let idx = params.len() + 1;
1145                    conditions.push(format!("name LIKE ?{}", idx));
1146                    params.push(Box::new(format!("%{}%", q)));
1147                }
1148                SearchField::Error => {
1149                    let idx = params.len() + 1;
1150                    conditions.push(format!("error LIKE ?{}", idx));
1151                    params.push(Box::new(format!("%{}%", q)));
1152                }
1153                _ => {
1154                    let idx1 = params.len() + 1;
1155                    let idx2 = params.len() + 2;
1156                    conditions.push(format!("(name LIKE ?{} OR error LIKE ?{})", idx1, idx2));
1157                    params.push(Box::new(format!("%{}%", q)));
1158                    params.push(Box::new(format!("%{}%", q)));
1159                }
1160            }
1161        }
1162
1163        if conditions.is_empty() {
1164            ("1=1".to_string(), params)
1165        } else {
1166            (conditions.join(" AND "), params)
1167        }
1168    }
1169
1170    /// Aggregate stats constrained to a filter (acquires the connection lock).
1171    /// Reserved for filtered dashboard endpoints; currently `list()` reuses the
1172    /// inner helper directly to avoid a double lock.
1173    #[allow(dead_code)]
1174    fn filtered_stats(&self, filter: &AgentListFilter) -> Result<FilteredStats> {
1175        let conn = self.conn.lock();
1176        self.filtered_stats_inner(filter, &conn)
1177    }
1178
1179    fn filtered_stats_inner(
1180        &self,
1181        filter: &AgentListFilter,
1182        conn: &rusqlite::Connection,
1183    ) -> Result<FilteredStats> {
1184        let (where_clause, params) = self.build_where(filter);
1185
1186        let sql = format!(
1187            "SELECT
1188                COALESCE(SUM(cost_usd), 0.0),
1189                COALESCE(SUM(tokens_input + tokens_output), 0),
1190                COALESCE(AVG(duration_secs), 0.0),
1191                COALESCE(SUM(CASE WHEN status='running' THEN 1 ELSE 0 END), 0),
1192                COALESCE(SUM(CASE WHEN status IN ('completed','stopped') THEN 1 ELSE 0 END), 0),
1193                COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), 0)
1194             FROM agents WHERE {}",
1195            where_clause
1196        );
1197
1198        let mut stmt = conn.prepare(&sql)?;
1199        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1200            params.iter().map(|p| p.as_ref()).collect();
1201        let row = stmt
1202            .query_row(param_refs.as_slice(), |row| {
1203                Ok(FilteredStats {
1204                    total_cost_usd: row.get(0)?,
1205                    total_tokens: row.get::<_, i64>(1)? as u64,
1206                    avg_duration_secs: row.get(2)?,
1207                    count_running: row.get::<_, i64>(3)? as u64,
1208                    count_completed: row.get::<_, i64>(4)? as u64,
1209                    count_failed: row.get::<_, i64>(5)? as u64,
1210                })
1211            })
1212            .context("Failed to compute filtered stats")?;
1213
1214        Ok(row)
1215    }
1216}
1217
1218// ===========================================================================
1219// Tests
1220// ===========================================================================
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225    use crate::types::AgentStatus;
1226
1227    fn make_test_db() -> (AgentLogDb, tempfile::TempDir) {
1228        let dir = tempfile::tempdir().unwrap();
1229        let path = dir.path().join("agent_log.db");
1230        let db = AgentLogDb::open(&path).unwrap();
1231        (db, dir)
1232    }
1233
1234    fn sample_agent(id: &str, status: AgentStatus, created: &str, cost: f64) -> AgentInfo {
1235        AgentInfo {
1236            id: uuid::Uuid::parse_str(id).unwrap(),
1237            name: format!("test-agent-{}", &id[..8]),
1238            status,
1239            created_at: DateTime::parse_from_rfc3339(created)
1240                .map(|dt| dt.with_timezone(&Utc))
1241                .unwrap(),
1242            project_id: None,
1243            started_at: None,
1244            completed_at: None,
1245            error: None,
1246            steps_completed: 5,
1247            steps_total: Some(10),
1248            tool_calls: vec![],
1249            tokens_input: 1000,
1250            tokens_output: 500,
1251            cost_usd: cost,
1252            model_id: "anthropic/claude-sonnet-4".into(),
1253            session_id: None,
1254        }
1255    }
1256
1257    #[test]
1258    fn test_upsert_and_query() {
1259        let (db, _dir) = make_test_db();
1260
1261        let agent = sample_agent(
1262            "550e8400-e29b-41d4-a716-446655440000",
1263            AgentStatus::Completed,
1264            "2026-06-01T00:00:00Z",
1265            0.05,
1266        );
1267
1268        db.upsert_agent(&agent).unwrap();
1269
1270        let filter = AgentListFilter::default();
1271        let result = db.query(&filter).unwrap();
1272        assert_eq!(result.total, 1);
1273        assert_eq!(result.items[0].name, "test-agent-550e8400");
1274        assert_eq!(result.items[0].status, AgentStatus::Completed);
1275    }
1276
1277    #[test]
1278    fn test_filter_by_status() {
1279        let (db, _dir) = make_test_db();
1280
1281        for i in 0..5 {
1282            let status = if i % 2 == 0 {
1283                AgentStatus::Completed
1284            } else {
1285                AgentStatus::Failed
1286            };
1287            db.upsert_agent(&sample_agent(
1288                &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1289                status,
1290                "2026-06-01T00:00:00Z",
1291                0.01,
1292            ))
1293            .unwrap();
1294        }
1295
1296        let filter = AgentListFilter {
1297            status: Some(AgentStatusFilter::Failed),
1298            ..Default::default()
1299        };
1300        let result = db.query(&filter).unwrap();
1301        assert_eq!(result.total, 2);
1302    }
1303
1304    #[test]
1305    fn test_filter_by_date_range() {
1306        let (db, _dir) = make_test_db();
1307
1308        for (i, (created, cost)) in [
1309            ("2026-06-01T00:00:00Z", 0.01),
1310            ("2026-06-10T00:00:00Z", 0.02),
1311            ("2026-06-20T00:00:00Z", 0.03),
1312        ]
1313        .iter()
1314        .enumerate()
1315        {
1316            db.upsert_agent(&sample_agent(
1317                &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1318                AgentStatus::Completed,
1319                created,
1320                *cost,
1321            ))
1322            .unwrap();
1323        }
1324
1325        let filter = AgentListFilter {
1326            date_from: Some(
1327                DateTime::parse_from_rfc3339("2026-06-05T00:00:00Z")
1328                    .map(|dt| dt.with_timezone(&Utc))
1329                    .unwrap(),
1330            ),
1331            date_to: Some(
1332                DateTime::parse_from_rfc3339("2026-06-15T00:00:00Z")
1333                    .map(|dt| dt.with_timezone(&Utc))
1334                    .unwrap(),
1335            ),
1336            ..Default::default()
1337        };
1338        let result = db.query(&filter).unwrap();
1339        assert_eq!(result.total, 1);
1340    }
1341
1342    #[test]
1343    fn test_search_by_name() {
1344        let (db, _dir) = make_test_db();
1345
1346        let mut agent = sample_agent(
1347            "550e8400-e29b-41d4-a716-446655440000",
1348            AgentStatus::Completed,
1349            "2026-06-01T00:00:00Z",
1350            0.01,
1351        );
1352        agent.name = "Refactor authentication module".into();
1353        db.upsert_agent(&agent).unwrap();
1354
1355        let mut agent2 = sample_agent(
1356            "550e8400-e29b-41d4-a716-446655440001",
1357            AgentStatus::Failed,
1358            "2026-06-02T00:00:00Z",
1359            0.02,
1360        );
1361        agent2.name = "Fix build error".into();
1362        db.upsert_agent(&agent2).unwrap();
1363
1364        let filter = AgentListFilter {
1365            q: Some("Refactor".into()),
1366            search_field: SearchField::Name,
1367            ..Default::default()
1368        };
1369        let result = db.query(&filter).unwrap();
1370        assert_eq!(result.total, 1);
1371        assert!(result.items[0].name.contains("Refactor"));
1372    }
1373
1374    #[test]
1375    fn test_sorting() {
1376        let (db, _dir) = make_test_db();
1377
1378        for (i, cost) in [0.10, 0.01, 0.50].iter().enumerate() {
1379            db.upsert_agent(&sample_agent(
1380                &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1381                AgentStatus::Completed,
1382                "2026-06-01T00:00:00Z",
1383                *cost,
1384            ))
1385            .unwrap();
1386        }
1387
1388        let filter = AgentListFilter {
1389            sort_by: SortBy::Cost,
1390            sort_dir: SortDir::Desc,
1391            ..Default::default()
1392        };
1393        let result = db.query(&filter).unwrap();
1394        assert_eq!(result.items[0].cost_usd, 0.50);
1395        assert_eq!(result.items[1].cost_usd, 0.10);
1396        assert_eq!(result.items[2].cost_usd, 0.01);
1397    }
1398
1399    #[test]
1400    fn test_pagination() {
1401        let (db, _dir) = make_test_db();
1402
1403        for i in 0..10 {
1404            db.upsert_agent(&sample_agent(
1405                &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1406                AgentStatus::Completed,
1407                &format!("2026-06-{:02}T00:00:00Z", i + 1),
1408                0.01,
1409            ))
1410            .unwrap();
1411        }
1412
1413        let mut filter = AgentListFilter {
1414            per_page: 3,
1415            page: 1,
1416            ..Default::default()
1417        };
1418        let result = db.query(&filter).unwrap();
1419        assert_eq!(result.items.len(), 3);
1420        assert_eq!(result.total_pages, 4);
1421
1422        filter.page = 2;
1423        let result = db.query(&filter).unwrap();
1424        assert_eq!(result.items.len(), 3);
1425    }
1426
1427    #[test]
1428    fn test_stats() {
1429        let (db, _dir) = make_test_db();
1430
1431        for (i, (status, cost)) in [
1432            (AgentStatus::Completed, 0.05),
1433            (AgentStatus::Completed, 0.03),
1434            (AgentStatus::Failed, 0.01),
1435            (AgentStatus::Running, 0.02),
1436        ]
1437        .iter()
1438        .enumerate()
1439        {
1440            db.upsert_agent(&sample_agent(
1441                &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1442                *status,
1443                "2026-06-01T00:00:00Z",
1444                *cost,
1445            ))
1446            .unwrap();
1447        }
1448
1449        let stats = db.stats().unwrap();
1450        assert_eq!(stats.total_agents, 4);
1451        assert_eq!(stats.completed, 2);
1452        assert_eq!(stats.failed, 1);
1453        assert_eq!(stats.running, 1);
1454        assert!((stats.total_cost_usd - 0.11).abs() < 0.001);
1455    }
1456
1457    #[test]
1458    fn test_prune_by_ttl() {
1459        let (db, _dir) = make_test_db();
1460
1461        db.upsert_agent(&sample_agent(
1462            "11111111-1111-4114-a716-446655440000",
1463            AgentStatus::Completed,
1464            "2026-03-01T00:00:00Z",
1465            0.01,
1466        ))
1467        .unwrap();
1468
1469        db.upsert_agent(&sample_agent(
1470            "22222222-2222-4114-a716-446655440000",
1471            AgentStatus::Completed,
1472            "2026-06-01T00:00:00Z",
1473            0.02,
1474        ))
1475        .unwrap();
1476
1477        let config = AgentLogConfig {
1478            ttl_hours: 720,
1479            ..Default::default()
1480        };
1481        let pruned = db.prune(&config).unwrap();
1482        assert!(pruned > 0);
1483
1484        let result = db.query(&AgentListFilter::default()).unwrap();
1485        assert_eq!(result.total, 1);
1486        assert_eq!(
1487            result.items[0].id.to_string(),
1488            "22222222-2222-4114-a716-446655440000"
1489        );
1490    }
1491
1492    #[test]
1493    fn test_delete() {
1494        let (db, _dir) = make_test_db();
1495
1496        db.upsert_agent(&sample_agent(
1497            "550e8400-e29b-41d4-a716-446655440000",
1498            AgentStatus::Completed,
1499            "2026-06-01T00:00:00Z",
1500            0.01,
1501        ))
1502        .unwrap();
1503
1504        assert!(db.delete("550e8400-e29b-41d4-a716-446655440000").unwrap());
1505        assert!(!db.delete("nonexistent").unwrap());
1506
1507        let result = db.query(&AgentListFilter::default()).unwrap();
1508        assert_eq!(result.total, 0);
1509    }
1510
1511    #[test]
1512    fn test_get_tool_calls() {
1513        let (db, _dir) = make_test_db();
1514
1515        let mut agent = sample_agent(
1516            "550e8400-e29b-41d4-a716-446655440000",
1517            AgentStatus::Completed,
1518            "2026-06-01T00:00:00Z",
1519            0.01,
1520        );
1521        agent.tool_calls = vec![
1522            ToolCallRecord {
1523                tool: "bash".into(),
1524                input: "ls -la".into(),
1525                output: "total 42".into(),
1526                duration_ms: 150,
1527                is_error: false,
1528                tool_call_id: "call_1".into(),
1529                timestamp: Some(Utc::now()),
1530            },
1531            ToolCallRecord {
1532                tool: "read".into(),
1533                input: "file.rs".into(),
1534                output: "fn main()".into(),
1535                duration_ms: 5,
1536                is_error: false,
1537                tool_call_id: "call_2".into(),
1538                timestamp: Some(Utc::now()),
1539            },
1540        ];
1541        db.upsert_agent(&agent).unwrap();
1542
1543        let calls = db
1544            .get_tool_calls("550e8400-e29b-41d4-a716-446655440000")
1545            .unwrap();
1546        assert_eq!(calls.len(), 2);
1547        assert_eq!(calls[0].tool, "bash");
1548        assert_eq!(calls[1].tool, "read");
1549    }
1550
1551    // ── Cost aggregation tests ──────────────────────────────────────
1552
1553    /// Helper: build an agent with configurable model/project/tokens.
1554    fn cost_agent(
1555        id: &str,
1556        model: &str,
1557        project: Option<&str>,
1558        created: &str,
1559        cost: f64,
1560        tokens_in: u64,
1561        tokens_out: u64,
1562    ) -> AgentInfo {
1563        let mut a = sample_agent(id, AgentStatus::Completed, created, cost);
1564        a.model_id = model.into();
1565        a.project_id = project.map(|p| uuid::Uuid::parse_str(p).unwrap());
1566        a.tokens_input = tokens_in;
1567        a.tokens_output = tokens_out;
1568        a
1569    }
1570
1571    #[test]
1572    fn test_cost_summary_all_time() {
1573        let (db, _dir) = make_test_db();
1574        db.upsert_agent(&cost_agent(
1575            "550e8400-e29b-41d4-a716-446655440001",
1576            "anthropic/claude-sonnet-4",
1577            None,
1578            "2025-01-15T10:00:00Z",
1579            0.10,
1580            1000,
1581            500,
1582        ))
1583        .unwrap();
1584        db.upsert_agent(&cost_agent(
1585            "550e8400-e29b-41d4-a716-446655440002",
1586            "openai/gpt-4o",
1587            None,
1588            "2025-06-20T10:00:00Z",
1589            0.25,
1590            2000,
1591            1000,
1592        ))
1593        .unwrap();
1594
1595        let s = db.cost_summary(None).unwrap();
1596        assert!((s.total_cost_usd - 0.35).abs() < 1e-9);
1597        assert_eq!(s.total_tokens, 4500); // (1000+500) + (2000+1000)
1598        assert_eq!(s.agent_count, 2);
1599    }
1600
1601    #[test]
1602    fn test_cost_summary_time_window() {
1603        let (db, _dir) = make_test_db();
1604        db.upsert_agent(&cost_agent(
1605            "550e8400-e29b-41d4-a716-446655440001",
1606            "anthropic/claude-sonnet-4",
1607            None,
1608            "2025-01-15T10:00:00Z",
1609            0.10,
1610            100,
1611            50,
1612        ))
1613        .unwrap();
1614        db.upsert_agent(&cost_agent(
1615            "550e8400-e29b-41d4-a716-446655440002",
1616            "openai/gpt-4o",
1617            None,
1618            "2025-06-24T10:00:00Z",
1619            0.25,
1620            200,
1621            100,
1622        ))
1623        .unwrap();
1624
1625        // Window starting 2025-06-01 should exclude the January agent.
1626        let since = DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
1627            .unwrap()
1628            .with_timezone(&Utc);
1629        let s = db.cost_summary(Some(since)).unwrap();
1630        assert!((s.total_cost_usd - 0.25).abs() < 1e-9);
1631        assert_eq!(s.agent_count, 1);
1632    }
1633
1634    #[test]
1635    fn test_cost_by_model_groups_and_sorts() {
1636        let (db, _dir) = make_test_db();
1637        db.upsert_agent(&cost_agent(
1638            "550e8400-e29b-41d4-a716-446655440001",
1639            "openai/gpt-4o",
1640            None,
1641            "2025-06-20T10:00:00Z",
1642            0.05,
1643            100,
1644            50,
1645        ))
1646        .unwrap();
1647        db.upsert_agent(&cost_agent(
1648            "550e8400-e29b-41d4-a716-446655440002",
1649            "openai/gpt-4o",
1650            None,
1651            "2025-06-21T10:00:00Z",
1652            0.10,
1653            100,
1654            50,
1655        ))
1656        .unwrap();
1657        db.upsert_agent(&cost_agent(
1658            "550e8400-e29b-41d4-a716-446655440003",
1659            "anthropic/claude-sonnet-4",
1660            None,
1661            "2025-06-22T10:00:00Z",
1662            0.50,
1663            500,
1664            200,
1665        ))
1666        .unwrap();
1667
1668        let rows = db.cost_by_model(None).unwrap();
1669        assert_eq!(rows.len(), 2);
1670        // Sorted by cost desc: claude (0.50) first, then gpt-4o (0.15).
1671        assert_eq!(rows[0].model_id, "anthropic/claude-sonnet-4");
1672        assert!((rows[0].cost_usd - 0.50).abs() < 1e-9);
1673        assert_eq!(rows[0].agent_count, 1);
1674        assert_eq!(rows[1].model_id, "openai/gpt-4o");
1675        assert!((rows[1].cost_usd - 0.15).abs() < 1e-9);
1676        assert_eq!(rows[1].agent_count, 2);
1677        assert_eq!(rows[1].tokens, 300); // 2 × (100+50)
1678    }
1679
1680    #[test]
1681    fn test_cost_by_project_excludes_null() {
1682        let (db, _dir) = make_test_db();
1683        let pid = "660e8400-e29b-41d4-a716-446655440099";
1684        // Agent WITH a project.
1685        db.upsert_agent(&cost_agent(
1686            "550e8400-e29b-41d4-a716-446655440001",
1687            "openai/gpt-4o",
1688            Some(pid),
1689            "2025-06-20T10:00:00Z",
1690            0.30,
1691            100,
1692            50,
1693        ))
1694        .unwrap();
1695        // Agent WITHOUT a project (project_id = None).
1696        db.upsert_agent(&cost_agent(
1697            "550e8400-e29b-41d4-a716-446655440002",
1698            "openai/gpt-4o",
1699            None,
1700            "2025-06-21T10:00:00Z",
1701            0.99,
1702            100,
1703            50,
1704        ))
1705        .unwrap();
1706
1707        let rows = db.cost_by_project(None).unwrap();
1708        // Only the project-tagged agent should appear.
1709        assert_eq!(rows.len(), 1);
1710        assert_eq!(rows[0].project_id, pid);
1711        assert!((rows[0].cost_usd - 0.30).abs() < 1e-9);
1712    }
1713
1714    #[test]
1715    fn test_cost_daily_groups_by_date() {
1716        let (db, _dir) = make_test_db();
1717        db.upsert_agent(&cost_agent(
1718            "550e8400-e29b-41d4-a716-446655440001",
1719            "openai/gpt-4o",
1720            None,
1721            "2026-06-23T08:00:00Z",
1722            0.10,
1723            100,
1724            50,
1725        ))
1726        .unwrap();
1727        db.upsert_agent(&cost_agent(
1728            "550e8400-e29b-41d4-a716-446655440002",
1729            "openai/gpt-4o",
1730            None,
1731            "2026-06-23T20:00:00Z",
1732            0.20,
1733            200,
1734            100,
1735        ))
1736        .unwrap();
1737        db.upsert_agent(&cost_agent(
1738            "550e8400-e29b-41d4-a716-446655440003",
1739            "openai/gpt-4o",
1740            None,
1741            "2026-06-24T10:00:00Z",
1742            0.05,
1743            50,
1744            25,
1745        ))
1746        .unwrap();
1747
1748        // 365-day window captures all three.
1749        let rows = db.cost_daily(365).unwrap();
1750        assert_eq!(rows.len(), 2); // two distinct dates
1751
1752        // Sorted ascending by date.
1753        assert_eq!(rows[0].date, "2026-06-23");
1754        assert!((rows[0].cost_usd - 0.30).abs() < 1e-9); // 0.10 + 0.20
1755        assert_eq!(rows[0].agent_count, 2);
1756        assert_eq!(rows[1].date, "2026-06-24");
1757        assert!((rows[1].cost_usd - 0.05).abs() < 1e-9);
1758    }
1759
1760    #[test]
1761    fn test_cost_aggregations_empty_db() {
1762        let (db, _dir) = make_test_db();
1763
1764        let s = db.cost_summary(None).unwrap();
1765        assert!((s.total_cost_usd).abs() < 1e-9);
1766        assert_eq!(s.agent_count, 0);
1767
1768        assert!(db.cost_by_model(None).unwrap().is_empty());
1769        assert!(db.cost_by_project(None).unwrap().is_empty());
1770        assert!(db.cost_daily(30).unwrap().is_empty());
1771    }
1772}