Skip to main content

oven_cli/db/
agent_runs.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, params};
3
4use super::{AgentRun, ReviewFinding};
5
6pub fn insert_agent_run(conn: &Connection, agent_run: &AgentRun) -> Result<i64> {
7    conn.execute(
8        "INSERT INTO agent_runs (run_id, agent, cycle, status, cost_usd, turns, \
9         started_at, finished_at, output_summary, error_message, raw_output) \
10         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
11        params![
12            agent_run.run_id,
13            agent_run.agent,
14            agent_run.cycle,
15            agent_run.status,
16            agent_run.cost_usd,
17            agent_run.turns,
18            agent_run.started_at,
19            agent_run.finished_at,
20            agent_run.output_summary,
21            agent_run.error_message,
22            agent_run.raw_output,
23        ],
24    )
25    .context("inserting agent run")?;
26    Ok(conn.last_insert_rowid())
27}
28
29pub fn get_agent_runs_for_run(conn: &Connection, run_id: &str) -> Result<Vec<AgentRun>> {
30    let mut stmt = conn
31        .prepare(
32            "SELECT id, run_id, agent, cycle, status, cost_usd, turns, \
33             started_at, finished_at, output_summary, error_message, raw_output \
34             FROM agent_runs WHERE run_id = ?1 ORDER BY id",
35        )
36        .context("preparing get_agent_runs_for_run")?;
37
38    let rows = stmt
39        .query_map(params![run_id], |row| {
40            Ok(AgentRun {
41                id: row.get(0)?,
42                run_id: row.get(1)?,
43                agent: row.get(2)?,
44                cycle: row.get(3)?,
45                status: row.get(4)?,
46                cost_usd: row.get(5)?,
47                turns: row.get(6)?,
48                started_at: row.get(7)?,
49                finished_at: row.get(8)?,
50                output_summary: row.get(9)?,
51                error_message: row.get(10)?,
52                raw_output: row.get(11)?,
53            })
54        })
55        .context("querying agent runs")?;
56
57    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting agent runs")
58}
59
60#[allow(clippy::too_many_arguments)]
61pub fn finish_agent_run(
62    conn: &Connection,
63    id: i64,
64    status: &str,
65    cost_usd: f64,
66    turns: u32,
67    output_summary: Option<&str>,
68    error_message: Option<&str>,
69    raw_output: Option<&str>,
70) -> Result<()> {
71    conn.execute(
72        "UPDATE agent_runs SET status = ?1, cost_usd = ?2, turns = ?3, \
73         finished_at = datetime('now'), output_summary = ?4, error_message = ?5, \
74         raw_output = ?6 WHERE id = ?7",
75        params![status, cost_usd, turns, output_summary, error_message, raw_output, id],
76    )
77    .context("finishing agent run")?;
78    Ok(())
79}
80
81pub fn insert_finding(conn: &Connection, finding: &ReviewFinding) -> Result<i64> {
82    conn.execute(
83        "INSERT INTO review_findings (agent_run_id, severity, category, file_path, \
84         line_number, message, resolved, dispute_reason) \
85         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
86        params![
87            finding.agent_run_id,
88            finding.severity,
89            finding.category,
90            finding.file_path,
91            finding.line_number,
92            finding.message,
93            finding.resolved,
94            finding.dispute_reason,
95        ],
96    )
97    .context("inserting finding")?;
98    Ok(conn.last_insert_rowid())
99}
100
101pub fn get_findings_for_agent_run(
102    conn: &Connection,
103    agent_run_id: i64,
104) -> Result<Vec<ReviewFinding>> {
105    let mut stmt = conn
106        .prepare(
107            "SELECT id, agent_run_id, severity, category, file_path, line_number, \
108             message, resolved, dispute_reason FROM review_findings WHERE agent_run_id = ?1",
109        )
110        .context("preparing get_findings_for_agent_run")?;
111
112    let rows =
113        stmt.query_map(params![agent_run_id], row_to_finding).context("querying findings")?;
114
115    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting findings")
116}
117
118pub fn get_unresolved_findings(conn: &Connection, run_id: &str) -> Result<Vec<ReviewFinding>> {
119    let mut stmt = conn
120        .prepare(
121            "SELECT f.id, f.agent_run_id, f.severity, f.category, f.file_path, \
122             f.line_number, f.message, f.resolved, f.dispute_reason \
123             FROM review_findings f \
124             JOIN agent_runs a ON f.agent_run_id = a.id \
125             WHERE a.run_id = ?1 AND f.resolved = 0 AND f.severity != 'info'",
126        )
127        .context("preparing get_unresolved_findings")?;
128
129    let rows =
130        stmt.query_map(params![run_id], row_to_finding).context("querying unresolved findings")?;
131
132    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting unresolved findings")
133}
134
135pub fn resolve_finding(conn: &Connection, finding_id: i64, reason: &str) -> Result<()> {
136    conn.execute(
137        "UPDATE review_findings SET resolved = 1, dispute_reason = ?2 WHERE id = ?1",
138        params![finding_id, reason],
139    )
140    .context("resolving finding")?;
141    Ok(())
142}
143
144fn row_to_finding(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReviewFinding> {
145    Ok(ReviewFinding {
146        id: row.get(0)?,
147        agent_run_id: row.get(1)?,
148        severity: row.get(2)?,
149        category: row.get(3)?,
150        file_path: row.get(4)?,
151        line_number: row.get(5)?,
152        message: row.get(6)?,
153        resolved: row.get(7)?,
154        dispute_reason: row.get(8)?,
155    })
156}
157
158pub fn get_resolved_findings(conn: &Connection, run_id: &str) -> Result<Vec<ReviewFinding>> {
159    let mut stmt = conn
160        .prepare(
161            "SELECT f.id, f.agent_run_id, f.severity, f.category, f.file_path, \
162             f.line_number, f.message, f.resolved, f.dispute_reason \
163             FROM review_findings f \
164             JOIN agent_runs a ON f.agent_run_id = a.id \
165             WHERE a.run_id = ?1 AND f.resolved = 1",
166        )
167        .context("preparing get_resolved_findings")?;
168
169    let rows =
170        stmt.query_map(params![run_id], row_to_finding).context("querying resolved findings")?;
171
172    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting resolved findings")
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::db::{self, RunStatus, runs};
179
180    fn test_db() -> Connection {
181        db::open_in_memory().unwrap()
182    }
183
184    fn insert_test_run(conn: &Connection, id: &str) {
185        runs::insert_run(
186            conn,
187            &db::Run {
188                id: id.to_string(),
189                issue_number: 1,
190                status: RunStatus::Pending,
191                pr_number: None,
192                branch: None,
193                worktree_path: None,
194                cost_usd: 0.0,
195                auto_merge: false,
196                started_at: "2026-03-12T00:00:00".to_string(),
197                finished_at: None,
198                error_message: None,
199                complexity: "full".to_string(),
200                issue_source: "github".to_string(),
201            },
202        )
203        .unwrap();
204    }
205
206    fn sample_agent_run(run_id: &str, agent: &str) -> AgentRun {
207        AgentRun {
208            id: 0,
209            run_id: run_id.to_string(),
210            agent: agent.to_string(),
211            cycle: 1,
212            status: "running".to_string(),
213            cost_usd: 0.0,
214            turns: 0,
215            started_at: "2026-03-12T00:00:00".to_string(),
216            finished_at: None,
217            output_summary: None,
218            error_message: None,
219            raw_output: None,
220        }
221    }
222
223    #[test]
224    fn insert_and_get_agent_run() {
225        let conn = test_db();
226        insert_test_run(&conn, "run1");
227
228        let ar = sample_agent_run("run1", "implementer");
229        let id = insert_agent_run(&conn, &ar).unwrap();
230        assert!(id > 0);
231
232        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
233        assert_eq!(runs.len(), 1);
234        assert_eq!(runs[0].agent, "implementer");
235    }
236
237    #[test]
238    fn finish_agent_run_updates_fields() {
239        let conn = test_db();
240        insert_test_run(&conn, "run1");
241        let id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
242
243        finish_agent_run(&conn, id, "complete", 1.50, 8, Some("all good"), None, None).unwrap();
244
245        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
246        assert_eq!(runs[0].status, "complete");
247        assert!((runs[0].cost_usd - 1.50).abs() < f64::EPSILON);
248        assert_eq!(runs[0].turns, 8);
249        assert_eq!(runs[0].output_summary.as_deref(), Some("all good"));
250        assert!(runs[0].finished_at.is_some());
251    }
252
253    #[test]
254    fn insert_and_get_findings() {
255        let conn = test_db();
256        insert_test_run(&conn, "run1");
257        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
258
259        let finding = ReviewFinding {
260            id: 0,
261            agent_run_id: ar_id,
262            severity: "critical".to_string(),
263            category: "bug".to_string(),
264            file_path: Some("src/main.rs".to_string()),
265            line_number: Some(42),
266            message: "null pointer".to_string(),
267            resolved: false,
268            dispute_reason: None,
269        };
270        let fid = insert_finding(&conn, &finding).unwrap();
271        assert!(fid > 0);
272
273        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
274        assert_eq!(findings.len(), 1);
275        assert_eq!(findings[0].severity, "critical");
276        assert_eq!(findings[0].message, "null pointer");
277    }
278
279    #[test]
280    fn resolve_finding_updates_flag() {
281        let conn = test_db();
282        insert_test_run(&conn, "run1");
283        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
284
285        let finding = ReviewFinding {
286            id: 0,
287            agent_run_id: ar_id,
288            severity: "warning".to_string(),
289            category: "style".to_string(),
290            file_path: None,
291            line_number: None,
292            message: "missing docs".to_string(),
293            resolved: false,
294            dispute_reason: None,
295        };
296        let fid = insert_finding(&conn, &finding).unwrap();
297
298        resolve_finding(&conn, fid, "test reason").unwrap();
299
300        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
301        assert!(findings[0].resolved);
302        assert_eq!(findings[0].dispute_reason.as_deref(), Some("test reason"));
303    }
304
305    #[test]
306    fn get_unresolved_findings_filters() {
307        let conn = test_db();
308        insert_test_run(&conn, "run1");
309        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
310
311        // Critical - unresolved
312        insert_finding(
313            &conn,
314            &ReviewFinding {
315                id: 0,
316                agent_run_id: ar_id,
317                severity: "critical".to_string(),
318                category: "bug".to_string(),
319                file_path: None,
320                line_number: None,
321                message: "bad".to_string(),
322                resolved: false,
323                dispute_reason: None,
324            },
325        )
326        .unwrap();
327
328        // Info - should be excluded
329        insert_finding(
330            &conn,
331            &ReviewFinding {
332                id: 0,
333                agent_run_id: ar_id,
334                severity: "info".to_string(),
335                category: "note".to_string(),
336                file_path: None,
337                line_number: None,
338                message: "fyi".to_string(),
339                resolved: false,
340                dispute_reason: None,
341            },
342        )
343        .unwrap();
344
345        // Warning - resolved, should be excluded
346        let wid = insert_finding(
347            &conn,
348            &ReviewFinding {
349                id: 0,
350                agent_run_id: ar_id,
351                severity: "warning".to_string(),
352                category: "style".to_string(),
353                file_path: None,
354                line_number: None,
355                message: "meh".to_string(),
356                resolved: false,
357                dispute_reason: None,
358            },
359        )
360        .unwrap();
361        resolve_finding(&conn, wid, "not applicable").unwrap();
362
363        let unresolved = get_unresolved_findings(&conn, "run1").unwrap();
364        assert_eq!(unresolved.len(), 1);
365        assert_eq!(unresolved[0].message, "bad");
366    }
367
368    #[test]
369    fn raw_output_round_trips() {
370        let conn = test_db();
371        insert_test_run(&conn, "run1");
372        let id = insert_agent_run(&conn, &sample_agent_run("run1", "implementer")).unwrap();
373
374        let raw = r#"{"batches":[{"batch":1,"issues":[]}]}"#;
375        finish_agent_run(&conn, id, "complete", 0.5, 3, Some("ok"), None, Some(raw)).unwrap();
376
377        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
378        assert_eq!(runs[0].raw_output.as_deref(), Some(raw));
379    }
380
381    #[test]
382    fn cascade_delete_removes_agent_runs_and_findings() {
383        let conn = test_db();
384        insert_test_run(&conn, "run1");
385        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
386        insert_finding(
387            &conn,
388            &ReviewFinding {
389                id: 0,
390                agent_run_id: ar_id,
391                severity: "critical".to_string(),
392                category: "bug".to_string(),
393                file_path: None,
394                line_number: None,
395                message: "bad".to_string(),
396                resolved: false,
397                dispute_reason: None,
398            },
399        )
400        .unwrap();
401
402        // Delete the run
403        conn.execute("DELETE FROM runs WHERE id = ?1", params!["run1"]).unwrap();
404
405        // Agent runs and findings should be gone
406        let agent_runs = get_agent_runs_for_run(&conn, "run1").unwrap();
407        assert!(agent_runs.is_empty());
408
409        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
410        assert!(findings.is_empty());
411    }
412
413    #[test]
414    fn resolve_finding_stores_dispute_reason() {
415        let conn = test_db();
416        insert_test_run(&conn, "run1");
417        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
418
419        let finding = ReviewFinding {
420            id: 0,
421            agent_run_id: ar_id,
422            severity: "critical".to_string(),
423            category: "convention".to_string(),
424            file_path: Some("src/app.rs".to_string()),
425            line_number: Some(10),
426            message: "missing estimatedItemSize".to_string(),
427            resolved: false,
428            dispute_reason: None,
429        };
430        let fid = insert_finding(&conn, &finding).unwrap();
431
432        resolve_finding(&conn, fid, "FlashList v2 removed this prop").unwrap();
433
434        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
435        assert!(findings[0].resolved);
436        assert_eq!(findings[0].dispute_reason.as_deref(), Some("FlashList v2 removed this prop"));
437    }
438
439    #[test]
440    fn get_resolved_findings_returns_only_resolved() {
441        let conn = test_db();
442        insert_test_run(&conn, "run1");
443        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
444
445        // Unresolved finding
446        insert_finding(
447            &conn,
448            &ReviewFinding {
449                id: 0,
450                agent_run_id: ar_id,
451                severity: "critical".to_string(),
452                category: "bug".to_string(),
453                file_path: None,
454                line_number: None,
455                message: "unresolved".to_string(),
456                resolved: false,
457                dispute_reason: None,
458            },
459        )
460        .unwrap();
461
462        // Resolved finding with reason
463        let fid = insert_finding(
464            &conn,
465            &ReviewFinding {
466                id: 0,
467                agent_run_id: ar_id,
468                severity: "warning".to_string(),
469                category: "convention".to_string(),
470                file_path: Some("src/lib.rs".to_string()),
471                line_number: None,
472                message: "disputed".to_string(),
473                resolved: false,
474                dispute_reason: None,
475            },
476        )
477        .unwrap();
478        resolve_finding(&conn, fid, "API does not exist").unwrap();
479
480        let resolved = get_resolved_findings(&conn, "run1").unwrap();
481        assert_eq!(resolved.len(), 1);
482        assert_eq!(resolved[0].message, "disputed");
483        assert_eq!(resolved[0].dispute_reason.as_deref(), Some("API does not exist"));
484    }
485
486    #[test]
487    fn get_resolved_findings_empty_when_none_resolved() {
488        let conn = test_db();
489        insert_test_run(&conn, "run1");
490        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
491
492        insert_finding(
493            &conn,
494            &ReviewFinding {
495                id: 0,
496                agent_run_id: ar_id,
497                severity: "critical".to_string(),
498                category: "bug".to_string(),
499                file_path: None,
500                line_number: None,
501                message: "not resolved".to_string(),
502                resolved: false,
503                dispute_reason: None,
504            },
505        )
506        .unwrap();
507
508        let resolved = get_resolved_findings(&conn, "run1").unwrap();
509        assert!(resolved.is_empty());
510    }
511}