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