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        // Anchor timestamps to `now` so the TTL boundary is deterministic
1462        // regardless of when the test runs (hardcoded calendar dates rot).
1463        let old = (Utc::now() - chrono::Duration::hours(800)).to_rfc3339();
1464        let recent = (Utc::now() - chrono::Duration::hours(100)).to_rfc3339();
1465
1466        db.upsert_agent(&sample_agent(
1467            "11111111-1111-4114-a716-446655440000",
1468            AgentStatus::Completed,
1469            &old,
1470            0.01,
1471        ))
1472        .unwrap();
1473
1474        db.upsert_agent(&sample_agent(
1475            "22222222-2222-4114-a716-446655440000",
1476            AgentStatus::Completed,
1477            &recent,
1478            0.02,
1479        ))
1480        .unwrap();
1481
1482        let config = AgentLogConfig {
1483            ttl_hours: 720,
1484            ..Default::default()
1485        };
1486        let pruned = db.prune(&config).unwrap();
1487        assert!(pruned > 0);
1488
1489        let result = db.query(&AgentListFilter::default()).unwrap();
1490        assert_eq!(result.total, 1);
1491        assert_eq!(
1492            result.items[0].id.to_string(),
1493            "22222222-2222-4114-a716-446655440000"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_delete() {
1499        let (db, _dir) = make_test_db();
1500
1501        db.upsert_agent(&sample_agent(
1502            "550e8400-e29b-41d4-a716-446655440000",
1503            AgentStatus::Completed,
1504            "2026-06-01T00:00:00Z",
1505            0.01,
1506        ))
1507        .unwrap();
1508
1509        assert!(db.delete("550e8400-e29b-41d4-a716-446655440000").unwrap());
1510        assert!(!db.delete("nonexistent").unwrap());
1511
1512        let result = db.query(&AgentListFilter::default()).unwrap();
1513        assert_eq!(result.total, 0);
1514    }
1515
1516    #[test]
1517    fn test_get_tool_calls() {
1518        let (db, _dir) = make_test_db();
1519
1520        let mut agent = sample_agent(
1521            "550e8400-e29b-41d4-a716-446655440000",
1522            AgentStatus::Completed,
1523            "2026-06-01T00:00:00Z",
1524            0.01,
1525        );
1526        agent.tool_calls = vec![
1527            ToolCallRecord {
1528                tool: "bash".into(),
1529                input: "ls -la".into(),
1530                output: "total 42".into(),
1531                duration_ms: 150,
1532                is_error: false,
1533                tool_call_id: "call_1".into(),
1534                timestamp: Some(Utc::now()),
1535            },
1536            ToolCallRecord {
1537                tool: "read".into(),
1538                input: "file.rs".into(),
1539                output: "fn main()".into(),
1540                duration_ms: 5,
1541                is_error: false,
1542                tool_call_id: "call_2".into(),
1543                timestamp: Some(Utc::now()),
1544            },
1545        ];
1546        db.upsert_agent(&agent).unwrap();
1547
1548        let calls = db
1549            .get_tool_calls("550e8400-e29b-41d4-a716-446655440000")
1550            .unwrap();
1551        assert_eq!(calls.len(), 2);
1552        assert_eq!(calls[0].tool, "bash");
1553        assert_eq!(calls[1].tool, "read");
1554    }
1555
1556    // ── Cost aggregation tests ──────────────────────────────────────
1557
1558    /// Helper: build an agent with configurable model/project/tokens.
1559    fn cost_agent(
1560        id: &str,
1561        model: &str,
1562        project: Option<&str>,
1563        created: &str,
1564        cost: f64,
1565        tokens_in: u64,
1566        tokens_out: u64,
1567    ) -> AgentInfo {
1568        let mut a = sample_agent(id, AgentStatus::Completed, created, cost);
1569        a.model_id = model.into();
1570        a.project_id = project.map(|p| uuid::Uuid::parse_str(p).unwrap());
1571        a.tokens_input = tokens_in;
1572        a.tokens_output = tokens_out;
1573        a
1574    }
1575
1576    #[test]
1577    fn test_cost_summary_all_time() {
1578        let (db, _dir) = make_test_db();
1579        db.upsert_agent(&cost_agent(
1580            "550e8400-e29b-41d4-a716-446655440001",
1581            "anthropic/claude-sonnet-4",
1582            None,
1583            "2025-01-15T10:00:00Z",
1584            0.10,
1585            1000,
1586            500,
1587        ))
1588        .unwrap();
1589        db.upsert_agent(&cost_agent(
1590            "550e8400-e29b-41d4-a716-446655440002",
1591            "openai/gpt-4o",
1592            None,
1593            "2025-06-20T10:00:00Z",
1594            0.25,
1595            2000,
1596            1000,
1597        ))
1598        .unwrap();
1599
1600        let s = db.cost_summary(None).unwrap();
1601        assert!((s.total_cost_usd - 0.35).abs() < 1e-9);
1602        assert_eq!(s.total_tokens, 4500); // (1000+500) + (2000+1000)
1603        assert_eq!(s.agent_count, 2);
1604    }
1605
1606    #[test]
1607    fn test_cost_summary_time_window() {
1608        let (db, _dir) = make_test_db();
1609        db.upsert_agent(&cost_agent(
1610            "550e8400-e29b-41d4-a716-446655440001",
1611            "anthropic/claude-sonnet-4",
1612            None,
1613            "2025-01-15T10:00:00Z",
1614            0.10,
1615            100,
1616            50,
1617        ))
1618        .unwrap();
1619        db.upsert_agent(&cost_agent(
1620            "550e8400-e29b-41d4-a716-446655440002",
1621            "openai/gpt-4o",
1622            None,
1623            "2025-06-24T10:00:00Z",
1624            0.25,
1625            200,
1626            100,
1627        ))
1628        .unwrap();
1629
1630        // Window starting 2025-06-01 should exclude the January agent.
1631        let since = DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
1632            .unwrap()
1633            .with_timezone(&Utc);
1634        let s = db.cost_summary(Some(since)).unwrap();
1635        assert!((s.total_cost_usd - 0.25).abs() < 1e-9);
1636        assert_eq!(s.agent_count, 1);
1637    }
1638
1639    #[test]
1640    fn test_cost_by_model_groups_and_sorts() {
1641        let (db, _dir) = make_test_db();
1642        db.upsert_agent(&cost_agent(
1643            "550e8400-e29b-41d4-a716-446655440001",
1644            "openai/gpt-4o",
1645            None,
1646            "2025-06-20T10:00:00Z",
1647            0.05,
1648            100,
1649            50,
1650        ))
1651        .unwrap();
1652        db.upsert_agent(&cost_agent(
1653            "550e8400-e29b-41d4-a716-446655440002",
1654            "openai/gpt-4o",
1655            None,
1656            "2025-06-21T10:00:00Z",
1657            0.10,
1658            100,
1659            50,
1660        ))
1661        .unwrap();
1662        db.upsert_agent(&cost_agent(
1663            "550e8400-e29b-41d4-a716-446655440003",
1664            "anthropic/claude-sonnet-4",
1665            None,
1666            "2025-06-22T10:00:00Z",
1667            0.50,
1668            500,
1669            200,
1670        ))
1671        .unwrap();
1672
1673        let rows = db.cost_by_model(None).unwrap();
1674        assert_eq!(rows.len(), 2);
1675        // Sorted by cost desc: claude (0.50) first, then gpt-4o (0.15).
1676        assert_eq!(rows[0].model_id, "anthropic/claude-sonnet-4");
1677        assert!((rows[0].cost_usd - 0.50).abs() < 1e-9);
1678        assert_eq!(rows[0].agent_count, 1);
1679        assert_eq!(rows[1].model_id, "openai/gpt-4o");
1680        assert!((rows[1].cost_usd - 0.15).abs() < 1e-9);
1681        assert_eq!(rows[1].agent_count, 2);
1682        assert_eq!(rows[1].tokens, 300); // 2 × (100+50)
1683    }
1684
1685    #[test]
1686    fn test_cost_by_project_excludes_null() {
1687        let (db, _dir) = make_test_db();
1688        let pid = "660e8400-e29b-41d4-a716-446655440099";
1689        // Agent WITH a project.
1690        db.upsert_agent(&cost_agent(
1691            "550e8400-e29b-41d4-a716-446655440001",
1692            "openai/gpt-4o",
1693            Some(pid),
1694            "2025-06-20T10:00:00Z",
1695            0.30,
1696            100,
1697            50,
1698        ))
1699        .unwrap();
1700        // Agent WITHOUT a project (project_id = None).
1701        db.upsert_agent(&cost_agent(
1702            "550e8400-e29b-41d4-a716-446655440002",
1703            "openai/gpt-4o",
1704            None,
1705            "2025-06-21T10:00:00Z",
1706            0.99,
1707            100,
1708            50,
1709        ))
1710        .unwrap();
1711
1712        let rows = db.cost_by_project(None).unwrap();
1713        // Only the project-tagged agent should appear.
1714        assert_eq!(rows.len(), 1);
1715        assert_eq!(rows[0].project_id, pid);
1716        assert!((rows[0].cost_usd - 0.30).abs() < 1e-9);
1717    }
1718
1719    #[test]
1720    fn test_cost_daily_groups_by_date() {
1721        let (db, _dir) = make_test_db();
1722        db.upsert_agent(&cost_agent(
1723            "550e8400-e29b-41d4-a716-446655440001",
1724            "openai/gpt-4o",
1725            None,
1726            "2026-06-23T08:00:00Z",
1727            0.10,
1728            100,
1729            50,
1730        ))
1731        .unwrap();
1732        db.upsert_agent(&cost_agent(
1733            "550e8400-e29b-41d4-a716-446655440002",
1734            "openai/gpt-4o",
1735            None,
1736            "2026-06-23T20:00:00Z",
1737            0.20,
1738            200,
1739            100,
1740        ))
1741        .unwrap();
1742        db.upsert_agent(&cost_agent(
1743            "550e8400-e29b-41d4-a716-446655440003",
1744            "openai/gpt-4o",
1745            None,
1746            "2026-06-24T10:00:00Z",
1747            0.05,
1748            50,
1749            25,
1750        ))
1751        .unwrap();
1752
1753        // 365-day window captures all three.
1754        let rows = db.cost_daily(365).unwrap();
1755        assert_eq!(rows.len(), 2); // two distinct dates
1756
1757        // Sorted ascending by date.
1758        assert_eq!(rows[0].date, "2026-06-23");
1759        assert!((rows[0].cost_usd - 0.30).abs() < 1e-9); // 0.10 + 0.20
1760        assert_eq!(rows[0].agent_count, 2);
1761        assert_eq!(rows[1].date, "2026-06-24");
1762        assert!((rows[1].cost_usd - 0.05).abs() < 1e-9);
1763    }
1764
1765    #[test]
1766    fn test_cost_aggregations_empty_db() {
1767        let (db, _dir) = make_test_db();
1768
1769        let s = db.cost_summary(None).unwrap();
1770        assert!((s.total_cost_usd).abs() < 1e-9);
1771        assert_eq!(s.agent_count, 0);
1772
1773        assert!(db.cost_by_model(None).unwrap().is_empty());
1774        assert!(db.cost_by_project(None).unwrap().is_empty());
1775        assert!(db.cost_daily(30).unwrap().is_empty());
1776    }
1777}