1use super::*;
2#[cfg(test)]
3use chrono::Datelike;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use sqlx::Row;
7
8#[derive(Debug, Clone)]
10pub struct InsertTaskMetric<'a> {
11 pub repo: &'a str,
12 pub task_id: &'a str,
13 pub agent: &'a str,
14 pub model: Option<&'a str>,
15 pub complexity: Option<&'a str>,
16 pub outcome: &'a str,
17 pub duration_seconds: f64,
18 pub started_at: &'a chrono::DateTime<Utc>,
19 pub completed_at: &'a chrono::DateTime<Utc>,
20 pub attempts: i32,
21 pub files_changed: i32,
22 pub error_type: Option<&'a str>,
23 pub input_tokens: Option<i64>,
24 pub output_tokens: Option<i64>,
25 pub input_cost_usd: Option<f64>,
26 pub output_cost_usd: Option<f64>,
27 pub total_cost_usd: Option<f64>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct MetricsSummary {
33 pub tasks_completed_24h: i64,
34 pub tasks_failed_24h: i64,
35 pub avg_duration_simple: Option<f64>,
36 pub avg_duration_medium: Option<f64>,
37 pub avg_duration_complex: Option<f64>,
38 pub agent_stats: Vec<AgentStat>,
39 pub rate_limits_24h: i64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AgentStat {
45 pub agent: String,
46 pub total_runs: i64,
47 pub success_count: i64,
48 pub success_rate: f64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SlowTaskInfo {
54 pub task_id: String,
55 pub agent: String,
56 pub complexity: Option<String>,
57 pub duration_seconds: f64,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ErrorStat {
63 pub error_type: Option<String>,
64 pub count: i64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct HighReviewCycleTask {
70 pub external_id: Option<String>,
71 pub agent: Option<String>,
72 pub review_cycles: i64,
73 pub title: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CostSummary {
79 pub periods: Vec<CostPeriod>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CostPeriod {
85 pub label: String,
86 pub input_tokens: i64,
87 pub output_tokens: i64,
88 pub input_cost_usd: f64,
89 pub output_cost_usd: f64,
90 pub total_cost_usd: f64,
91 pub task_count: i64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CostByGroup {
97 pub name: String,
98 pub input_tokens: i64,
99 pub output_tokens: i64,
100 pub total_cost_usd: f64,
101 pub task_count: i64,
102}
103
104#[cfg(test)]
107fn self_improvement_key() -> String {
108 let now = Utc::now();
109 format!(
110 "self_improvement_issues_{}_w{}",
111 now.iso_week().year(),
112 now.iso_week().week()
113 )
114}
115
116impl TaskStore {
117 pub async fn cost_summary(&self, repo: &str) -> anyhow::Result<(i64, i64, f64)> {
118 let row = sqlx::query(
119 "SELECT
120 COALESCE(SUM(input_tokens), 0) as total_input,
121 COALESCE(SUM(output_tokens), 0) as total_output,
122 COALESCE(SUM(total_cost_usd), 0.0) as total_cost
123 FROM tasks WHERE repo = ?",
124 )
125 .bind(repo)
126 .fetch_one(&self.pool)
127 .await?;
128
129 use sqlx::Row;
130 Ok((
131 row.try_get::<i64, _>("total_input")?,
132 row.try_get::<i64, _>("total_output")?,
133 row.try_get::<f64, _>("total_cost")?,
134 ))
135 }
136
137 #[cfg(test)]
141 pub async fn status_counts(
142 &self,
143 repo: &str,
144 ) -> anyhow::Result<std::collections::HashMap<String, i64>> {
145 let rows =
146 sqlx::query("SELECT status, COUNT(*) as cnt FROM tasks WHERE repo = ? GROUP BY status")
147 .bind(repo)
148 .fetch_all(&self.pool)
149 .await?;
150
151 use sqlx::Row;
152 let mut map = std::collections::HashMap::new();
153 for row in &rows {
154 let status: String = row.try_get("status")?;
155 let count: i64 = row.try_get("cnt")?;
156 map.insert(status, count);
157 }
158 Ok(map)
159 }
160
161 pub async fn insert_task_metric(&self, metric: &InsertTaskMetric<'_>) -> anyhow::Result<i64> {
162 let mut tx = self.pool.begin().await?;
163
164 sqlx::query(
165 "DELETE FROM task_metrics WHERE datetime(completed_at) < datetime('now', '-90 days')",
166 )
167 .execute(&mut *tx)
168 .await?;
169
170 let row = sqlx::query(
171 "INSERT INTO task_metrics (repo, task_id, agent, model, complexity, outcome, duration_seconds,
172 started_at, completed_at, attempts, files_changed, error_type,
173 input_tokens, output_tokens, input_cost_usd, output_cost_usd, total_cost_usd)
174 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175 RETURNING id",
176 )
177 .bind(metric.repo)
178 .bind(metric.task_id)
179 .bind(metric.agent)
180 .bind(metric.model)
181 .bind(metric.complexity)
182 .bind(metric.outcome)
183 .bind(metric.duration_seconds)
184 .bind(metric.started_at.to_rfc3339())
185 .bind(metric.completed_at.to_rfc3339())
186 .bind(metric.attempts)
187 .bind(metric.files_changed)
188 .bind(metric.error_type)
189 .bind(metric.input_tokens)
190 .bind(metric.output_tokens)
191 .bind(metric.input_cost_usd)
192 .bind(metric.output_cost_usd)
193 .bind(metric.total_cost_usd)
194 .fetch_one(&mut *tx)
195 .await?;
196
197 tx.commit().await?;
198 Ok(row.try_get("id")?)
199 }
200
201 pub async fn get_metrics_summary(&self, hours: u32) -> anyhow::Result<MetricsSummary> {
206 let interval = format!("-{hours} hours");
207
208 let row = sqlx::query(
209 "SELECT
210 COALESCE(SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END), 0) AS completed,
211 COALESCE(SUM(CASE WHEN outcome != 'success' THEN 1 ELSE 0 END), 0) AS failed,
212 AVG(CASE WHEN complexity = 'simple' THEN duration_seconds END) AS avg_simple,
213 AVG(CASE WHEN complexity = 'medium' THEN duration_seconds END) AS avg_medium,
214 AVG(CASE WHEN complexity = 'complex' THEN duration_seconds END) AS avg_complex
215 FROM task_metrics
216 WHERE datetime(completed_at) >= datetime('now', ?)",
217 )
218 .bind(&interval)
219 .fetch_one(&self.pool)
220 .await?;
221
222 let completed: i64 = row.try_get("completed")?;
223 let failed: i64 = row.try_get("failed")?;
224 let avg_simple: Option<f64> = row.try_get("avg_simple")?;
226 let avg_medium: Option<f64> = row.try_get("avg_medium")?;
227 let avg_complex: Option<f64> = row.try_get("avg_complex")?;
228
229 let agent_rows = sqlx::query(
230 "SELECT agent, COUNT(*) as total,
231 COALESCE(SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END), 0) as success_count
232 FROM task_metrics
233 WHERE datetime(completed_at) >= datetime('now', ?)
234 GROUP BY agent",
235 )
236 .bind(&interval)
237 .fetch_all(&self.pool)
238 .await?;
239
240 let agent_stats: Vec<AgentStat> = agent_rows
241 .iter()
242 .map(|row| {
243 let total: i64 = row.try_get("total")?;
244 let success: i64 = row.try_get("success_count")?;
245 Ok::<AgentStat, sqlx::Error>(AgentStat {
246 agent: row.try_get("agent")?,
247 total_runs: total,
248 success_count: success,
249 success_rate: if total > 0 {
250 (success as f64 / total as f64) * 100.0
251 } else {
252 0.0
253 },
254 })
255 })
256 .collect::<Result<Vec<_>, _>>()?;
257
258 let rate_limit_count: (i64,) = sqlx::query_as(
259 "SELECT COUNT(*) FROM rate_limits WHERE occurred_at >= datetime('now', ?)",
260 )
261 .bind(&interval)
262 .fetch_one(&self.pool)
263 .await?;
264
265 Ok(MetricsSummary {
266 tasks_completed_24h: completed,
267 tasks_failed_24h: failed,
268 avg_duration_simple: avg_simple,
269 avg_duration_medium: avg_medium,
270 avg_duration_complex: avg_complex,
271 agent_stats,
272 rate_limits_24h: rate_limit_count.0,
273 })
274 }
275
276 #[cfg(test)]
280 pub async fn get_metrics_summary_24h(&self) -> anyhow::Result<MetricsSummary> {
281 self.get_metrics_summary(24).await
282 }
283
284 pub async fn get_metrics_summary_by_repo(
291 &self,
292 repo: &str,
293 hours: u32,
294 ) -> anyhow::Result<MetricsSummary> {
295 let interval = format!("-{hours} hours");
296
297 let rows = sqlx::query(
298 "WITH base AS (
299 SELECT m.outcome, m.complexity, m.duration_seconds, m.agent,
300 COALESCE(NULLIF(m.repo, ''), t.repo) AS resolved_repo
301 FROM task_metrics m
302 LEFT JOIN tasks t ON m.task_id = CAST(t.id AS TEXT)
303 OR (m.task_id = t.external_id AND NOT EXISTS (
304 SELECT 1 FROM tasks t2 WHERE t2.id = CAST(m.task_id AS INTEGER) AND t2.repo = t.repo
305 ))
306 WHERE datetime(m.completed_at) >= datetime('now', ?)
307 )
308 SELECT
309 COALESCE(SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END), 0) AS completed,
310 COALESCE(SUM(CASE WHEN outcome != 'success' THEN 1 ELSE 0 END), 0) AS failed,
311 AVG(CASE WHEN complexity = 'simple' THEN duration_seconds END) AS avg_simple,
312 AVG(CASE WHEN complexity = 'medium' THEN duration_seconds END) AS avg_medium,
313 AVG(CASE WHEN complexity = 'complex' THEN duration_seconds END) AS avg_complex
314 FROM base
315 WHERE resolved_repo = ?",
316 )
317 .bind(&interval)
318 .bind(repo)
319 .fetch_one(&self.pool)
320 .await?;
321
322 let completed: i64 = rows.try_get("completed")?;
323 let failed: i64 = rows.try_get("failed")?;
324 let avg_simple: Option<f64> = rows.try_get("avg_simple")?;
326 let avg_medium: Option<f64> = rows.try_get("avg_medium")?;
327 let avg_complex: Option<f64> = rows.try_get("avg_complex")?;
328
329 let agent_rows = sqlx::query(
330 "SELECT m.agent, COUNT(*) as total,
331 COALESCE(SUM(CASE WHEN m.outcome = 'success' THEN 1 ELSE 0 END), 0) as success_count
332 FROM task_metrics m
333 LEFT JOIN tasks t ON m.task_id = CAST(t.id AS TEXT)
334 OR (m.task_id = t.external_id AND NOT EXISTS (
335 SELECT 1 FROM tasks t2 WHERE t2.id = CAST(m.task_id AS INTEGER) AND t2.repo = t.repo
336 ))
337 WHERE datetime(m.completed_at) >= datetime('now', ?)
338 AND COALESCE(NULLIF(m.repo, ''), t.repo) = ?
339 GROUP BY m.agent",
340 )
341 .bind(&interval)
342 .bind(repo)
343 .fetch_all(&self.pool)
344 .await?;
345
346 let agent_stats: Vec<AgentStat> = agent_rows
347 .iter()
348 .map(|row| {
349 let total: i64 = row.try_get("total")?;
350 let success: i64 = row.try_get("success_count")?;
351 Ok::<AgentStat, sqlx::Error>(AgentStat {
352 agent: row.try_get("agent")?,
353 total_runs: total,
354 success_count: success,
355 success_rate: if total > 0 {
356 (success as f64 / total as f64) * 100.0
357 } else {
358 0.0
359 },
360 })
361 })
362 .collect::<Result<Vec<_>, _>>()?;
363
364 let rate_limits: (i64,) = sqlx::query_as(
365 "SELECT COUNT(*)
366 FROM rate_limits r
367 LEFT JOIN tasks t ON (
368 -- If any task exists with this value as an external_id, prefer
369 -- external_id matching (global preference). This avoids mapping a
370 -- rate-limit that references an external task to an unrelated
371 -- repo that merely happens to have an internal numeric id equal
372 -- to the same string.
373 (EXISTS (SELECT 1 FROM tasks te WHERE te.external_id = r.task_id) AND r.task_id = t.external_id)
374 OR
375 (NOT EXISTS (SELECT 1 FROM tasks te WHERE te.external_id = r.task_id) AND r.task_id = CAST(t.id AS TEXT))
376 )
377 WHERE r.occurred_at >= datetime('now', ?)
378 AND t.repo = ?",
379 )
380 .bind(&interval)
381 .bind(repo)
382 .fetch_one(&self.pool)
383 .await?;
384
385 Ok(MetricsSummary {
386 tasks_completed_24h: completed,
387 tasks_failed_24h: failed,
388 avg_duration_simple: avg_simple,
389 avg_duration_medium: avg_medium,
390 avg_duration_complex: avg_complex,
391 agent_stats,
392 rate_limits_24h: rate_limits.0,
393 })
394 }
395
396 pub async fn get_cost_summary_by_repo(
402 &self,
403 repo: &str,
404 hours: u32,
405 ) -> anyhow::Result<CostSummary> {
406 let interval = format!("-{hours} hours");
407 let row = sqlx::query(
408 "SELECT COALESCE(SUM(m.input_tokens), 0) as input_tokens,
409 COALESCE(SUM(m.output_tokens), 0) as output_tokens,
410 COALESCE(SUM(m.input_cost_usd), 0.0) as input_cost_usd,
411 COALESCE(SUM(m.output_cost_usd), 0.0) as output_cost_usd,
412 COALESCE(SUM(m.total_cost_usd), 0.0) as total_cost_usd,
413 COUNT(*) as task_count
414 FROM task_metrics m
415 LEFT JOIN tasks t ON m.task_id = CAST(t.id AS TEXT)
416 OR (m.task_id = t.external_id AND NOT EXISTS (
417 SELECT 1 FROM tasks t2 WHERE t2.id = CAST(m.task_id AS INTEGER) AND t2.repo = t.repo
418 ))
419 WHERE datetime(m.completed_at) >= datetime('now', ?)
420 AND COALESCE(NULLIF(m.repo, ''), t.repo) = ?",
421 )
422 .bind(&interval)
423 .bind(repo)
424 .fetch_one(&self.pool)
425 .await?;
426
427 Ok(CostSummary {
428 periods: vec![CostPeriod {
429 label: if hours == 24 {
430 "24h".to_string()
431 } else if hours.is_multiple_of(24) {
432 format!("{}d", hours / 24)
433 } else {
434 format!("{hours}h")
435 },
436 input_tokens: row.try_get("input_tokens")?,
437 output_tokens: row.try_get("output_tokens")?,
438 input_cost_usd: row.try_get("input_cost_usd")?,
439 output_cost_usd: row.try_get("output_cost_usd")?,
440 total_cost_usd: row.try_get("total_cost_usd")?,
441 task_count: row.try_get("task_count")?,
442 }],
443 })
444 }
445
446 pub async fn record_rate_limit(
448 &self,
449 agent: &str,
450 limit_type: &str,
451 task_id: Option<&str>,
452 ) -> anyhow::Result<i64> {
453 let mut tx = self.pool.begin().await?;
454
455 sqlx::query("DELETE FROM rate_limits WHERE occurred_at < datetime('now', '-30 days')")
456 .execute(&mut *tx)
457 .await?;
458
459 let row = sqlx::query(
460 "INSERT INTO rate_limits (agent, limit_type, occurred_at, task_id)
461 VALUES (?, ?, datetime('now'), ?)
462 RETURNING id",
463 )
464 .bind(agent)
465 .bind(limit_type)
466 .bind(task_id)
467 .fetch_one(&mut *tx)
468 .await?;
469
470 tx.commit().await?;
471 Ok(row.try_get("id")?)
472 }
473
474 pub async fn record_rate_limit_at(
476 &self,
477 agent: &str,
478 limit_type: &str,
479 task_id: Option<&str>,
480 occurred_at: &str,
481 ) -> anyhow::Result<i64> {
482 let mut tx = self.pool.begin().await?;
483
484 sqlx::query("DELETE FROM rate_limits WHERE occurred_at < datetime('now', '-30 days')")
485 .execute(&mut *tx)
486 .await?;
487
488 let row = sqlx::query(
489 "INSERT INTO rate_limits (agent, limit_type, occurred_at, task_id)
490 VALUES (?, ?, ?, ?)
491 RETURNING id",
492 )
493 .bind(agent)
494 .bind(limit_type)
495 .bind(occurred_at)
496 .bind(task_id)
497 .fetch_one(&mut *tx)
498 .await?;
499
500 tx.commit().await?;
501 Ok(row.try_get("id")?)
502 }
503
504 pub async fn rate_limit_agents(&self) -> anyhow::Result<Vec<String>> {
506 let rows = sqlx::query("SELECT DISTINCT agent FROM rate_limits")
507 .fetch_all(&self.pool)
508 .await?;
509 let mut agents = Vec::with_capacity(rows.len());
510 for row in &rows {
511 agents.push(row.try_get("agent")?);
512 }
513 Ok(agents)
514 }
515
516 pub async fn recent_rate_limit_counts(
523 &self,
524 window_hours: u32,
525 ) -> anyhow::Result<std::collections::HashMap<String, i64>> {
526 let interval = format!("-{window_hours} hours");
530 let rows = sqlx::query(
531 "SELECT r.agent, COUNT(*) as cnt
532 FROM rate_limits r
533 LEFT JOIN kv ON kv.key = 'rate_limits_cleared_at:' || r.agent
534 WHERE r.occurred_at >= datetime('now', ?)
535 AND r.limit_type = 'rate'
536 AND (
537 kv.value IS NULL
538 OR r.occurred_at > datetime(CAST(kv.value AS INTEGER), 'unixepoch')
539 )
540 GROUP BY r.agent",
541 )
542 .bind(&interval)
543 .fetch_all(&self.pool)
544 .await?;
545
546 let mut map = std::collections::HashMap::new();
547 for row in &rows {
548 let agent: String = row.try_get("agent")?;
549 let cnt: i64 = row.try_get("cnt")?;
550 map.insert(agent, cnt);
551 }
552 Ok(map)
553 }
554
555 pub async fn get_slow_tasks(&self, hours: u32) -> anyhow::Result<Vec<SlowTaskInfo>> {
557 let interval = format!("-{hours} hours");
558 let rows = sqlx::query(
559 "SELECT task_id, agent, complexity, duration_seconds
560 FROM task_metrics
561 WHERE datetime(completed_at) >= datetime('now', ?) AND outcome = 'success'
562 ORDER BY duration_seconds DESC
563 LIMIT 10",
564 )
565 .bind(&interval)
566 .fetch_all(&self.pool)
567 .await?;
568
569 rows.iter()
570 .map(|row| {
571 Ok(SlowTaskInfo {
572 task_id: row.try_get("task_id")?,
573 agent: row.try_get("agent")?,
574 complexity: row.try_get("complexity")?,
576 duration_seconds: row.try_get("duration_seconds")?,
577 })
578 })
579 .collect()
580 }
581
582 #[cfg(test)]
585 pub async fn get_slow_tasks_7d(&self) -> anyhow::Result<Vec<SlowTaskInfo>> {
586 let rows = sqlx::query(
587 "SELECT task_id, agent, complexity, duration_seconds
588 FROM task_metrics
589 WHERE datetime(completed_at) >= datetime('now', '-7 days') AND outcome = 'success'
590 ORDER BY duration_seconds DESC
591 LIMIT 10",
592 )
593 .fetch_all(&self.pool)
594 .await?;
595
596 rows.iter()
597 .map(|row| {
598 Ok(SlowTaskInfo {
599 task_id: row.try_get("task_id")?,
600 agent: row.try_get("agent")?,
601 complexity: row.try_get("complexity")?,
603 duration_seconds: row.try_get("duration_seconds")?,
604 })
605 })
606 .collect()
607 }
608
609 pub async fn get_error_distribution(&self, hours: u32) -> anyhow::Result<Vec<ErrorStat>> {
611 let interval = format!("-{hours} hours");
612 let rows = sqlx::query(
613 "SELECT error_type, COUNT(*) as count
614 FROM task_metrics
615 WHERE datetime(completed_at) >= datetime('now', ?)
616 AND outcome != 'success'
617 AND error_type IS NOT NULL
618 GROUP BY error_type
619 ORDER BY count DESC",
620 )
621 .bind(&interval)
622 .fetch_all(&self.pool)
623 .await?;
624
625 rows.iter()
626 .map(|row| {
627 Ok(ErrorStat {
628 error_type: row.try_get("error_type")?,
630 count: row.try_get("count")?,
631 })
632 })
633 .collect()
634 }
635
636 pub async fn get_cost_summary(&self) -> anyhow::Result<CostSummary> {
641 let windows: &[(&str, &str)] = &[
643 (
644 "SELECT COALESCE(SUM(input_tokens), 0) as input_tokens,
645 COALESCE(SUM(output_tokens), 0) as output_tokens,
646 COALESCE(SUM(input_cost_usd), 0.0) as input_cost_usd,
647 COALESCE(SUM(output_cost_usd), 0.0) as output_cost_usd,
648 COALESCE(SUM(total_cost_usd), 0.0) as total_cost_usd,
649 COUNT(*) as task_count
650 FROM task_metrics
651 WHERE datetime(completed_at) >= datetime('now', '-24 hours')",
652 "24h",
653 ),
654 (
655 "SELECT COALESCE(SUM(input_tokens), 0) as input_tokens,
656 COALESCE(SUM(output_tokens), 0) as output_tokens,
657 COALESCE(SUM(input_cost_usd), 0.0) as input_cost_usd,
658 COALESCE(SUM(output_cost_usd), 0.0) as output_cost_usd,
659 COALESCE(SUM(total_cost_usd), 0.0) as total_cost_usd,
660 COUNT(*) as task_count
661 FROM task_metrics
662 WHERE datetime(completed_at) >= datetime('now', '-7 days')",
663 "7d",
664 ),
665 (
666 "SELECT COALESCE(SUM(input_tokens), 0) as input_tokens,
667 COALESCE(SUM(output_tokens), 0) as output_tokens,
668 COALESCE(SUM(input_cost_usd), 0.0) as input_cost_usd,
669 COALESCE(SUM(output_cost_usd), 0.0) as output_cost_usd,
670 COALESCE(SUM(total_cost_usd), 0.0) as total_cost_usd,
671 COUNT(*) as task_count
672 FROM task_metrics
673 WHERE datetime(completed_at) >= datetime('now', '-30 days')",
674 "30d",
675 ),
676 ];
677
678 let mut periods = Vec::new();
679 for (query, label) in windows {
680 let row = sqlx::query(query).fetch_one(&self.pool).await?;
681 periods.push(CostPeriod {
682 label: label.to_string(),
683 input_tokens: row.try_get("input_tokens")?,
684 output_tokens: row.try_get("output_tokens")?,
685 input_cost_usd: row.try_get("input_cost_usd")?,
686 output_cost_usd: row.try_get("output_cost_usd")?,
687 total_cost_usd: row.try_get("total_cost_usd")?,
688 task_count: row.try_get("task_count")?,
689 });
690 }
691
692 Ok(CostSummary { periods })
693 }
694
695 pub async fn get_cost_by_agent(&self) -> anyhow::Result<Vec<CostByGroup>> {
697 let rows = sqlx::query(
698 "SELECT agent as name,
699 COALESCE(SUM(input_tokens), 0) as input_tokens,
700 COALESCE(SUM(output_tokens), 0) as output_tokens,
701 COALESCE(SUM(total_cost_usd), 0.0) as total_cost_usd,
702 COUNT(*) as task_count
703 FROM task_metrics
704 GROUP BY agent
705 ORDER BY SUM(total_cost_usd) DESC",
706 )
707 .fetch_all(&self.pool)
708 .await?;
709
710 rows.iter()
711 .map(|row| {
712 Ok(CostByGroup {
713 name: row.try_get("name")?,
714 input_tokens: row.try_get("input_tokens")?,
715 output_tokens: row.try_get("output_tokens")?,
716 total_cost_usd: row.try_get("total_cost_usd")?,
717 task_count: row.try_get("task_count")?,
718 })
719 })
720 .collect()
721 }
722
723 pub async fn get_cost_by_model(&self) -> anyhow::Result<Vec<CostByGroup>> {
725 let rows = sqlx::query(
726 "SELECT COALESCE(model, 'unknown') as name,
727 COALESCE(SUM(input_tokens), 0) as input_tokens,
728 COALESCE(SUM(output_tokens), 0) as output_tokens,
729 COALESCE(SUM(total_cost_usd), 0.0) as total_cost_usd,
730 COUNT(*) as task_count
731 FROM task_metrics
732 GROUP BY model
733 ORDER BY SUM(total_cost_usd) DESC",
734 )
735 .fetch_all(&self.pool)
736 .await?;
737
738 rows.iter()
739 .map(|row| {
740 Ok(CostByGroup {
741 name: row.try_get("name")?,
742 input_tokens: row.try_get("input_tokens")?,
743 output_tokens: row.try_get("output_tokens")?,
744 total_cost_usd: row.try_get("total_cost_usd")?,
745 task_count: row.try_get("task_count")?,
746 })
747 })
748 .collect()
749 }
750
751 pub async fn latest_task_metric_duration(&self, task_id: &str) -> Option<f64> {
754 sqlx::query_scalar(
755 "SELECT duration_seconds FROM task_metrics WHERE task_id = ? ORDER BY completed_at DESC LIMIT 1",
756 )
757 .bind(task_id)
758 .fetch_optional(&self.pool)
759 .await
760 .ok()
761 .flatten()
762 }
763
764 #[cfg(test)]
767 pub async fn count_self_improvement_issues_7d(&self) -> anyhow::Result<i64> {
768 let key = self_improvement_key();
769 let count = self.kv_get(&key).await?;
770 Ok(count.and_then(|c| c.parse().ok()).unwrap_or(0))
771 }
772
773 pub async fn get_high_review_cycle_tasks(
775 &self,
776 hours: u32,
777 ) -> anyhow::Result<Vec<HighReviewCycleTask>> {
778 let interval = format!("-{hours} hours");
779 let rows = sqlx::query(
780 "SELECT external_id, agent, review_cycles, title
781 FROM tasks
782 WHERE review_cycles >= 2
783 AND datetime(updated_at) >= datetime('now', ?)
784 ORDER BY review_cycles DESC
785 LIMIT 10",
786 )
787 .bind(&interval)
788 .fetch_all(&self.pool)
789 .await?;
790
791 rows.iter()
792 .map(|row| {
793 Ok(HighReviewCycleTask {
794 external_id: row.try_get("external_id")?,
796 agent: row.try_get("agent")?,
797 review_cycles: row.try_get("review_cycles")?,
798 title: row.try_get("title")?,
799 })
800 })
801 .collect()
802 }
803
804 #[cfg(test)]
807 pub async fn get_high_review_cycle_tasks_7d(&self) -> anyhow::Result<Vec<HighReviewCycleTask>> {
808 let rows = sqlx::query(
809 "SELECT external_id, agent, review_cycles, title
810 FROM tasks
811 WHERE review_cycles >= 2
812 AND datetime(updated_at) >= datetime('now', '-7 days')
813 ORDER BY review_cycles DESC
814 LIMIT 10",
815 )
816 .fetch_all(&self.pool)
817 .await?;
818
819 rows.iter()
820 .map(|row| {
821 Ok(HighReviewCycleTask {
822 external_id: row.try_get("external_id")?,
824 agent: row.try_get("agent")?,
825 review_cycles: row.try_get("review_cycles")?,
826 title: row.try_get("title")?,
827 })
828 })
829 .collect()
830 }
831
832 #[cfg(test)]
836 pub async fn increment_self_improvement_counter(&self) -> anyhow::Result<()> {
837 let key = self_improvement_key();
838 sqlx::query(
839 "INSERT INTO kv (key, value, updated_at) VALUES (?, '1', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
840 ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
841 )
842 .bind(&key)
843 .execute(&self.pool)
844 .await?;
845 Ok(())
846 }
847}