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