1use 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#[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 #[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#[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 #[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#[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 #[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#[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#[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 #[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#[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#[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#[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#[derive(Debug, Clone, Default)]
265pub struct RebuildReport {
266 pub reindexed: u64,
267 pub orphaned: u64,
268 pub errors: u64,
269}
270#[derive(Debug, Clone, Default, serde::Serialize)]
276pub struct CostSummary {
277 pub total_cost_usd: f64,
279 pub total_tokens: u64,
281 pub agent_count: u64,
283}
284
285#[derive(Debug, Clone, serde::Serialize)]
287pub struct ModelCostRow {
288 pub model_id: String,
290 pub cost_usd: f64,
292 pub tokens: u64,
294 pub agent_count: u64,
296}
297
298#[derive(Debug, Clone, serde::Serialize)]
300pub struct ProjectCostRow {
301 pub project_id: String,
303 pub cost_usd: f64,
305 pub tokens: u64,
307 pub agent_count: u64,
309}
310
311#[derive(Debug, Clone, serde::Serialize)]
313pub struct DailyCostRow {
314 pub date: String,
316 pub cost_usd: f64,
318 pub tokens: u64,
320 pub agent_count: u64,
322}
323
324#[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 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 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 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 let _ = conn.execute_batch(
499 "INSERT INTO agent_tool_calls_fts(agent_tool_calls_fts) VALUES('rebuild');",
500 );
501
502 Ok(())
503 }
504
505 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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#[cfg(test)]
1223mod tests {
1224 use super::*;
1225 use crate::types::AgentStatus;
1226
1227 fn make_test_db() -> (AgentLogDb, tempfile::TempDir) {
1228 let dir = tempfile::tempdir().unwrap();
1229 let path = dir.path().join("agent_log.db");
1230 let db = AgentLogDb::open(&path).unwrap();
1231 (db, dir)
1232 }
1233
1234 fn sample_agent(id: &str, status: AgentStatus, created: &str, cost: f64) -> AgentInfo {
1235 AgentInfo {
1236 id: uuid::Uuid::parse_str(id).unwrap(),
1237 name: format!("test-agent-{}", &id[..8]),
1238 status,
1239 created_at: DateTime::parse_from_rfc3339(created)
1240 .map(|dt| dt.with_timezone(&Utc))
1241 .unwrap(),
1242 project_id: None,
1243 started_at: None,
1244 completed_at: None,
1245 error: None,
1246 steps_completed: 5,
1247 steps_total: Some(10),
1248 tool_calls: vec![],
1249 tokens_input: 1000,
1250 tokens_output: 500,
1251 cost_usd: cost,
1252 model_id: "anthropic/claude-sonnet-4".into(),
1253 session_id: None,
1254 }
1255 }
1256
1257 #[test]
1258 fn test_upsert_and_query() {
1259 let (db, _dir) = make_test_db();
1260
1261 let agent = sample_agent(
1262 "550e8400-e29b-41d4-a716-446655440000",
1263 AgentStatus::Completed,
1264 "2026-06-01T00:00:00Z",
1265 0.05,
1266 );
1267
1268 db.upsert_agent(&agent).unwrap();
1269
1270 let filter = AgentListFilter::default();
1271 let result = db.query(&filter).unwrap();
1272 assert_eq!(result.total, 1);
1273 assert_eq!(result.items[0].name, "test-agent-550e8400");
1274 assert_eq!(result.items[0].status, AgentStatus::Completed);
1275 }
1276
1277 #[test]
1278 fn test_filter_by_status() {
1279 let (db, _dir) = make_test_db();
1280
1281 for i in 0..5 {
1282 let status = if i % 2 == 0 {
1283 AgentStatus::Completed
1284 } else {
1285 AgentStatus::Failed
1286 };
1287 db.upsert_agent(&sample_agent(
1288 &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1289 status,
1290 "2026-06-01T00:00:00Z",
1291 0.01,
1292 ))
1293 .unwrap();
1294 }
1295
1296 let filter = AgentListFilter {
1297 status: Some(AgentStatusFilter::Failed),
1298 ..Default::default()
1299 };
1300 let result = db.query(&filter).unwrap();
1301 assert_eq!(result.total, 2);
1302 }
1303
1304 #[test]
1305 fn test_filter_by_date_range() {
1306 let (db, _dir) = make_test_db();
1307
1308 for (i, (created, cost)) in [
1309 ("2026-06-01T00:00:00Z", 0.01),
1310 ("2026-06-10T00:00:00Z", 0.02),
1311 ("2026-06-20T00:00:00Z", 0.03),
1312 ]
1313 .iter()
1314 .enumerate()
1315 {
1316 db.upsert_agent(&sample_agent(
1317 &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1318 AgentStatus::Completed,
1319 created,
1320 *cost,
1321 ))
1322 .unwrap();
1323 }
1324
1325 let filter = AgentListFilter {
1326 date_from: Some(
1327 DateTime::parse_from_rfc3339("2026-06-05T00:00:00Z")
1328 .map(|dt| dt.with_timezone(&Utc))
1329 .unwrap(),
1330 ),
1331 date_to: Some(
1332 DateTime::parse_from_rfc3339("2026-06-15T00:00:00Z")
1333 .map(|dt| dt.with_timezone(&Utc))
1334 .unwrap(),
1335 ),
1336 ..Default::default()
1337 };
1338 let result = db.query(&filter).unwrap();
1339 assert_eq!(result.total, 1);
1340 }
1341
1342 #[test]
1343 fn test_search_by_name() {
1344 let (db, _dir) = make_test_db();
1345
1346 let mut agent = sample_agent(
1347 "550e8400-e29b-41d4-a716-446655440000",
1348 AgentStatus::Completed,
1349 "2026-06-01T00:00:00Z",
1350 0.01,
1351 );
1352 agent.name = "Refactor authentication module".into();
1353 db.upsert_agent(&agent).unwrap();
1354
1355 let mut agent2 = sample_agent(
1356 "550e8400-e29b-41d4-a716-446655440001",
1357 AgentStatus::Failed,
1358 "2026-06-02T00:00:00Z",
1359 0.02,
1360 );
1361 agent2.name = "Fix build error".into();
1362 db.upsert_agent(&agent2).unwrap();
1363
1364 let filter = AgentListFilter {
1365 q: Some("Refactor".into()),
1366 search_field: SearchField::Name,
1367 ..Default::default()
1368 };
1369 let result = db.query(&filter).unwrap();
1370 assert_eq!(result.total, 1);
1371 assert!(result.items[0].name.contains("Refactor"));
1372 }
1373
1374 #[test]
1375 fn test_sorting() {
1376 let (db, _dir) = make_test_db();
1377
1378 for (i, cost) in [0.10, 0.01, 0.50].iter().enumerate() {
1379 db.upsert_agent(&sample_agent(
1380 &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1381 AgentStatus::Completed,
1382 "2026-06-01T00:00:00Z",
1383 *cost,
1384 ))
1385 .unwrap();
1386 }
1387
1388 let filter = AgentListFilter {
1389 sort_by: SortBy::Cost,
1390 sort_dir: SortDir::Desc,
1391 ..Default::default()
1392 };
1393 let result = db.query(&filter).unwrap();
1394 assert_eq!(result.items[0].cost_usd, 0.50);
1395 assert_eq!(result.items[1].cost_usd, 0.10);
1396 assert_eq!(result.items[2].cost_usd, 0.01);
1397 }
1398
1399 #[test]
1400 fn test_pagination() {
1401 let (db, _dir) = make_test_db();
1402
1403 for i in 0..10 {
1404 db.upsert_agent(&sample_agent(
1405 &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1406 AgentStatus::Completed,
1407 &format!("2026-06-{:02}T00:00:00Z", i + 1),
1408 0.01,
1409 ))
1410 .unwrap();
1411 }
1412
1413 let mut filter = AgentListFilter {
1414 per_page: 3,
1415 page: 1,
1416 ..Default::default()
1417 };
1418 let result = db.query(&filter).unwrap();
1419 assert_eq!(result.items.len(), 3);
1420 assert_eq!(result.total_pages, 4);
1421
1422 filter.page = 2;
1423 let result = db.query(&filter).unwrap();
1424 assert_eq!(result.items.len(), 3);
1425 }
1426
1427 #[test]
1428 fn test_stats() {
1429 let (db, _dir) = make_test_db();
1430
1431 for (i, (status, cost)) in [
1432 (AgentStatus::Completed, 0.05),
1433 (AgentStatus::Completed, 0.03),
1434 (AgentStatus::Failed, 0.01),
1435 (AgentStatus::Running, 0.02),
1436 ]
1437 .iter()
1438 .enumerate()
1439 {
1440 db.upsert_agent(&sample_agent(
1441 &format!("550e8400-e29b-41d4-a716-44665544000{}", i),
1442 *status,
1443 "2026-06-01T00:00:00Z",
1444 *cost,
1445 ))
1446 .unwrap();
1447 }
1448
1449 let stats = db.stats().unwrap();
1450 assert_eq!(stats.total_agents, 4);
1451 assert_eq!(stats.completed, 2);
1452 assert_eq!(stats.failed, 1);
1453 assert_eq!(stats.running, 1);
1454 assert!((stats.total_cost_usd - 0.11).abs() < 0.001);
1455 }
1456
1457 #[test]
1458 fn test_prune_by_ttl() {
1459 let (db, _dir) = make_test_db();
1460
1461 db.upsert_agent(&sample_agent(
1462 "11111111-1111-4114-a716-446655440000",
1463 AgentStatus::Completed,
1464 "2026-03-01T00:00:00Z",
1465 0.01,
1466 ))
1467 .unwrap();
1468
1469 db.upsert_agent(&sample_agent(
1470 "22222222-2222-4114-a716-446655440000",
1471 AgentStatus::Completed,
1472 "2026-06-01T00:00:00Z",
1473 0.02,
1474 ))
1475 .unwrap();
1476
1477 let config = AgentLogConfig {
1478 ttl_hours: 720,
1479 ..Default::default()
1480 };
1481 let pruned = db.prune(&config).unwrap();
1482 assert!(pruned > 0);
1483
1484 let result = db.query(&AgentListFilter::default()).unwrap();
1485 assert_eq!(result.total, 1);
1486 assert_eq!(
1487 result.items[0].id.to_string(),
1488 "22222222-2222-4114-a716-446655440000"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_delete() {
1494 let (db, _dir) = make_test_db();
1495
1496 db.upsert_agent(&sample_agent(
1497 "550e8400-e29b-41d4-a716-446655440000",
1498 AgentStatus::Completed,
1499 "2026-06-01T00:00:00Z",
1500 0.01,
1501 ))
1502 .unwrap();
1503
1504 assert!(db.delete("550e8400-e29b-41d4-a716-446655440000").unwrap());
1505 assert!(!db.delete("nonexistent").unwrap());
1506
1507 let result = db.query(&AgentListFilter::default()).unwrap();
1508 assert_eq!(result.total, 0);
1509 }
1510
1511 #[test]
1512 fn test_get_tool_calls() {
1513 let (db, _dir) = make_test_db();
1514
1515 let mut agent = sample_agent(
1516 "550e8400-e29b-41d4-a716-446655440000",
1517 AgentStatus::Completed,
1518 "2026-06-01T00:00:00Z",
1519 0.01,
1520 );
1521 agent.tool_calls = vec![
1522 ToolCallRecord {
1523 tool: "bash".into(),
1524 input: "ls -la".into(),
1525 output: "total 42".into(),
1526 duration_ms: 150,
1527 is_error: false,
1528 tool_call_id: "call_1".into(),
1529 timestamp: Some(Utc::now()),
1530 },
1531 ToolCallRecord {
1532 tool: "read".into(),
1533 input: "file.rs".into(),
1534 output: "fn main()".into(),
1535 duration_ms: 5,
1536 is_error: false,
1537 tool_call_id: "call_2".into(),
1538 timestamp: Some(Utc::now()),
1539 },
1540 ];
1541 db.upsert_agent(&agent).unwrap();
1542
1543 let calls = db
1544 .get_tool_calls("550e8400-e29b-41d4-a716-446655440000")
1545 .unwrap();
1546 assert_eq!(calls.len(), 2);
1547 assert_eq!(calls[0].tool, "bash");
1548 assert_eq!(calls[1].tool, "read");
1549 }
1550
1551 fn cost_agent(
1555 id: &str,
1556 model: &str,
1557 project: Option<&str>,
1558 created: &str,
1559 cost: f64,
1560 tokens_in: u64,
1561 tokens_out: u64,
1562 ) -> AgentInfo {
1563 let mut a = sample_agent(id, AgentStatus::Completed, created, cost);
1564 a.model_id = model.into();
1565 a.project_id = project.map(|p| uuid::Uuid::parse_str(p).unwrap());
1566 a.tokens_input = tokens_in;
1567 a.tokens_output = tokens_out;
1568 a
1569 }
1570
1571 #[test]
1572 fn test_cost_summary_all_time() {
1573 let (db, _dir) = make_test_db();
1574 db.upsert_agent(&cost_agent(
1575 "550e8400-e29b-41d4-a716-446655440001",
1576 "anthropic/claude-sonnet-4",
1577 None,
1578 "2025-01-15T10:00:00Z",
1579 0.10,
1580 1000,
1581 500,
1582 ))
1583 .unwrap();
1584 db.upsert_agent(&cost_agent(
1585 "550e8400-e29b-41d4-a716-446655440002",
1586 "openai/gpt-4o",
1587 None,
1588 "2025-06-20T10:00:00Z",
1589 0.25,
1590 2000,
1591 1000,
1592 ))
1593 .unwrap();
1594
1595 let s = db.cost_summary(None).unwrap();
1596 assert!((s.total_cost_usd - 0.35).abs() < 1e-9);
1597 assert_eq!(s.total_tokens, 4500); assert_eq!(s.agent_count, 2);
1599 }
1600
1601 #[test]
1602 fn test_cost_summary_time_window() {
1603 let (db, _dir) = make_test_db();
1604 db.upsert_agent(&cost_agent(
1605 "550e8400-e29b-41d4-a716-446655440001",
1606 "anthropic/claude-sonnet-4",
1607 None,
1608 "2025-01-15T10:00:00Z",
1609 0.10,
1610 100,
1611 50,
1612 ))
1613 .unwrap();
1614 db.upsert_agent(&cost_agent(
1615 "550e8400-e29b-41d4-a716-446655440002",
1616 "openai/gpt-4o",
1617 None,
1618 "2025-06-24T10:00:00Z",
1619 0.25,
1620 200,
1621 100,
1622 ))
1623 .unwrap();
1624
1625 let since = DateTime::parse_from_rfc3339("2025-06-01T00:00:00Z")
1627 .unwrap()
1628 .with_timezone(&Utc);
1629 let s = db.cost_summary(Some(since)).unwrap();
1630 assert!((s.total_cost_usd - 0.25).abs() < 1e-9);
1631 assert_eq!(s.agent_count, 1);
1632 }
1633
1634 #[test]
1635 fn test_cost_by_model_groups_and_sorts() {
1636 let (db, _dir) = make_test_db();
1637 db.upsert_agent(&cost_agent(
1638 "550e8400-e29b-41d4-a716-446655440001",
1639 "openai/gpt-4o",
1640 None,
1641 "2025-06-20T10:00:00Z",
1642 0.05,
1643 100,
1644 50,
1645 ))
1646 .unwrap();
1647 db.upsert_agent(&cost_agent(
1648 "550e8400-e29b-41d4-a716-446655440002",
1649 "openai/gpt-4o",
1650 None,
1651 "2025-06-21T10:00:00Z",
1652 0.10,
1653 100,
1654 50,
1655 ))
1656 .unwrap();
1657 db.upsert_agent(&cost_agent(
1658 "550e8400-e29b-41d4-a716-446655440003",
1659 "anthropic/claude-sonnet-4",
1660 None,
1661 "2025-06-22T10:00:00Z",
1662 0.50,
1663 500,
1664 200,
1665 ))
1666 .unwrap();
1667
1668 let rows = db.cost_by_model(None).unwrap();
1669 assert_eq!(rows.len(), 2);
1670 assert_eq!(rows[0].model_id, "anthropic/claude-sonnet-4");
1672 assert!((rows[0].cost_usd - 0.50).abs() < 1e-9);
1673 assert_eq!(rows[0].agent_count, 1);
1674 assert_eq!(rows[1].model_id, "openai/gpt-4o");
1675 assert!((rows[1].cost_usd - 0.15).abs() < 1e-9);
1676 assert_eq!(rows[1].agent_count, 2);
1677 assert_eq!(rows[1].tokens, 300); }
1679
1680 #[test]
1681 fn test_cost_by_project_excludes_null() {
1682 let (db, _dir) = make_test_db();
1683 let pid = "660e8400-e29b-41d4-a716-446655440099";
1684 db.upsert_agent(&cost_agent(
1686 "550e8400-e29b-41d4-a716-446655440001",
1687 "openai/gpt-4o",
1688 Some(pid),
1689 "2025-06-20T10:00:00Z",
1690 0.30,
1691 100,
1692 50,
1693 ))
1694 .unwrap();
1695 db.upsert_agent(&cost_agent(
1697 "550e8400-e29b-41d4-a716-446655440002",
1698 "openai/gpt-4o",
1699 None,
1700 "2025-06-21T10:00:00Z",
1701 0.99,
1702 100,
1703 50,
1704 ))
1705 .unwrap();
1706
1707 let rows = db.cost_by_project(None).unwrap();
1708 assert_eq!(rows.len(), 1);
1710 assert_eq!(rows[0].project_id, pid);
1711 assert!((rows[0].cost_usd - 0.30).abs() < 1e-9);
1712 }
1713
1714 #[test]
1715 fn test_cost_daily_groups_by_date() {
1716 let (db, _dir) = make_test_db();
1717 db.upsert_agent(&cost_agent(
1718 "550e8400-e29b-41d4-a716-446655440001",
1719 "openai/gpt-4o",
1720 None,
1721 "2026-06-23T08:00:00Z",
1722 0.10,
1723 100,
1724 50,
1725 ))
1726 .unwrap();
1727 db.upsert_agent(&cost_agent(
1728 "550e8400-e29b-41d4-a716-446655440002",
1729 "openai/gpt-4o",
1730 None,
1731 "2026-06-23T20:00:00Z",
1732 0.20,
1733 200,
1734 100,
1735 ))
1736 .unwrap();
1737 db.upsert_agent(&cost_agent(
1738 "550e8400-e29b-41d4-a716-446655440003",
1739 "openai/gpt-4o",
1740 None,
1741 "2026-06-24T10:00:00Z",
1742 0.05,
1743 50,
1744 25,
1745 ))
1746 .unwrap();
1747
1748 let rows = db.cost_daily(365).unwrap();
1750 assert_eq!(rows.len(), 2); assert_eq!(rows[0].date, "2026-06-23");
1754 assert!((rows[0].cost_usd - 0.30).abs() < 1e-9); assert_eq!(rows[0].agent_count, 2);
1756 assert_eq!(rows[1].date, "2026-06-24");
1757 assert!((rows[1].cost_usd - 0.05).abs() < 1e-9);
1758 }
1759
1760 #[test]
1761 fn test_cost_aggregations_empty_db() {
1762 let (db, _dir) = make_test_db();
1763
1764 let s = db.cost_summary(None).unwrap();
1765 assert!((s.total_cost_usd).abs() < 1e-9);
1766 assert_eq!(s.agent_count, 0);
1767
1768 assert!(db.cost_by_model(None).unwrap().is_empty());
1769 assert!(db.cost_by_project(None).unwrap().is_empty());
1770 assert!(db.cost_daily(30).unwrap().is_empty());
1771 }
1772}