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