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) \
85         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
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        ],
95    )
96    .context("inserting finding")?;
97    Ok(conn.last_insert_rowid())
98}
99
100pub fn get_findings_for_agent_run(
101    conn: &Connection,
102    agent_run_id: i64,
103) -> Result<Vec<ReviewFinding>> {
104    let mut stmt = conn
105        .prepare(
106            "SELECT id, agent_run_id, severity, category, file_path, line_number, \
107             message, resolved FROM review_findings WHERE agent_run_id = ?1",
108        )
109        .context("preparing get_findings_for_agent_run")?;
110
111    let rows =
112        stmt.query_map(params![agent_run_id], row_to_finding).context("querying findings")?;
113
114    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting findings")
115}
116
117pub fn get_unresolved_findings(conn: &Connection, run_id: &str) -> Result<Vec<ReviewFinding>> {
118    let mut stmt = conn
119        .prepare(
120            "SELECT f.id, f.agent_run_id, f.severity, f.category, f.file_path, \
121             f.line_number, f.message, f.resolved \
122             FROM review_findings f \
123             JOIN agent_runs a ON f.agent_run_id = a.id \
124             WHERE a.run_id = ?1 AND f.resolved = 0 AND f.severity != 'info'",
125        )
126        .context("preparing get_unresolved_findings")?;
127
128    let rows =
129        stmt.query_map(params![run_id], row_to_finding).context("querying unresolved findings")?;
130
131    rows.collect::<std::result::Result<Vec<_>, _>>().context("collecting unresolved findings")
132}
133
134pub fn resolve_finding(conn: &Connection, finding_id: i64) -> Result<()> {
135    conn.execute("UPDATE review_findings SET resolved = 1 WHERE id = ?1", params![finding_id])
136        .context("resolving finding")?;
137    Ok(())
138}
139
140fn row_to_finding(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReviewFinding> {
141    Ok(ReviewFinding {
142        id: row.get(0)?,
143        agent_run_id: row.get(1)?,
144        severity: row.get(2)?,
145        category: row.get(3)?,
146        file_path: row.get(4)?,
147        line_number: row.get(5)?,
148        message: row.get(6)?,
149        resolved: row.get(7)?,
150    })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::db::{self, RunStatus, runs};
157
158    fn test_db() -> Connection {
159        db::open_in_memory().unwrap()
160    }
161
162    fn insert_test_run(conn: &Connection, id: &str) {
163        runs::insert_run(
164            conn,
165            &db::Run {
166                id: id.to_string(),
167                issue_number: 1,
168                status: RunStatus::Pending,
169                pr_number: None,
170                branch: None,
171                worktree_path: None,
172                cost_usd: 0.0,
173                auto_merge: false,
174                started_at: "2026-03-12T00:00:00".to_string(),
175                finished_at: None,
176                error_message: None,
177                complexity: "full".to_string(),
178                issue_source: "github".to_string(),
179            },
180        )
181        .unwrap();
182    }
183
184    fn sample_agent_run(run_id: &str, agent: &str) -> AgentRun {
185        AgentRun {
186            id: 0,
187            run_id: run_id.to_string(),
188            agent: agent.to_string(),
189            cycle: 1,
190            status: "running".to_string(),
191            cost_usd: 0.0,
192            turns: 0,
193            started_at: "2026-03-12T00:00:00".to_string(),
194            finished_at: None,
195            output_summary: None,
196            error_message: None,
197            raw_output: None,
198        }
199    }
200
201    #[test]
202    fn insert_and_get_agent_run() {
203        let conn = test_db();
204        insert_test_run(&conn, "run1");
205
206        let ar = sample_agent_run("run1", "implementer");
207        let id = insert_agent_run(&conn, &ar).unwrap();
208        assert!(id > 0);
209
210        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
211        assert_eq!(runs.len(), 1);
212        assert_eq!(runs[0].agent, "implementer");
213    }
214
215    #[test]
216    fn finish_agent_run_updates_fields() {
217        let conn = test_db();
218        insert_test_run(&conn, "run1");
219        let id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
220
221        finish_agent_run(&conn, id, "complete", 1.50, 8, Some("all good"), None, None).unwrap();
222
223        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
224        assert_eq!(runs[0].status, "complete");
225        assert!((runs[0].cost_usd - 1.50).abs() < f64::EPSILON);
226        assert_eq!(runs[0].turns, 8);
227        assert_eq!(runs[0].output_summary.as_deref(), Some("all good"));
228        assert!(runs[0].finished_at.is_some());
229    }
230
231    #[test]
232    fn insert_and_get_findings() {
233        let conn = test_db();
234        insert_test_run(&conn, "run1");
235        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
236
237        let finding = ReviewFinding {
238            id: 0,
239            agent_run_id: ar_id,
240            severity: "critical".to_string(),
241            category: "bug".to_string(),
242            file_path: Some("src/main.rs".to_string()),
243            line_number: Some(42),
244            message: "null pointer".to_string(),
245            resolved: false,
246        };
247        let fid = insert_finding(&conn, &finding).unwrap();
248        assert!(fid > 0);
249
250        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
251        assert_eq!(findings.len(), 1);
252        assert_eq!(findings[0].severity, "critical");
253        assert_eq!(findings[0].message, "null pointer");
254    }
255
256    #[test]
257    fn resolve_finding_updates_flag() {
258        let conn = test_db();
259        insert_test_run(&conn, "run1");
260        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
261
262        let finding = ReviewFinding {
263            id: 0,
264            agent_run_id: ar_id,
265            severity: "warning".to_string(),
266            category: "style".to_string(),
267            file_path: None,
268            line_number: None,
269            message: "missing docs".to_string(),
270            resolved: false,
271        };
272        let fid = insert_finding(&conn, &finding).unwrap();
273
274        resolve_finding(&conn, fid).unwrap();
275
276        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
277        assert!(findings[0].resolved);
278    }
279
280    #[test]
281    fn get_unresolved_findings_filters() {
282        let conn = test_db();
283        insert_test_run(&conn, "run1");
284        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
285
286        // Critical - unresolved
287        insert_finding(
288            &conn,
289            &ReviewFinding {
290                id: 0,
291                agent_run_id: ar_id,
292                severity: "critical".to_string(),
293                category: "bug".to_string(),
294                file_path: None,
295                line_number: None,
296                message: "bad".to_string(),
297                resolved: false,
298            },
299        )
300        .unwrap();
301
302        // Info - should be excluded
303        insert_finding(
304            &conn,
305            &ReviewFinding {
306                id: 0,
307                agent_run_id: ar_id,
308                severity: "info".to_string(),
309                category: "note".to_string(),
310                file_path: None,
311                line_number: None,
312                message: "fyi".to_string(),
313                resolved: false,
314            },
315        )
316        .unwrap();
317
318        // Warning - resolved, should be excluded
319        let wid = insert_finding(
320            &conn,
321            &ReviewFinding {
322                id: 0,
323                agent_run_id: ar_id,
324                severity: "warning".to_string(),
325                category: "style".to_string(),
326                file_path: None,
327                line_number: None,
328                message: "meh".to_string(),
329                resolved: false,
330            },
331        )
332        .unwrap();
333        resolve_finding(&conn, wid).unwrap();
334
335        let unresolved = get_unresolved_findings(&conn, "run1").unwrap();
336        assert_eq!(unresolved.len(), 1);
337        assert_eq!(unresolved[0].message, "bad");
338    }
339
340    #[test]
341    fn raw_output_round_trips() {
342        let conn = test_db();
343        insert_test_run(&conn, "run1");
344        let id = insert_agent_run(&conn, &sample_agent_run("run1", "implementer")).unwrap();
345
346        let raw = r#"{"batches":[{"batch":1,"issues":[]}]}"#;
347        finish_agent_run(&conn, id, "complete", 0.5, 3, Some("ok"), None, Some(raw)).unwrap();
348
349        let runs = get_agent_runs_for_run(&conn, "run1").unwrap();
350        assert_eq!(runs[0].raw_output.as_deref(), Some(raw));
351    }
352
353    #[test]
354    fn cascade_delete_removes_agent_runs_and_findings() {
355        let conn = test_db();
356        insert_test_run(&conn, "run1");
357        let ar_id = insert_agent_run(&conn, &sample_agent_run("run1", "reviewer")).unwrap();
358        insert_finding(
359            &conn,
360            &ReviewFinding {
361                id: 0,
362                agent_run_id: ar_id,
363                severity: "critical".to_string(),
364                category: "bug".to_string(),
365                file_path: None,
366                line_number: None,
367                message: "bad".to_string(),
368                resolved: false,
369            },
370        )
371        .unwrap();
372
373        // Delete the run
374        conn.execute("DELETE FROM runs WHERE id = ?1", params!["run1"]).unwrap();
375
376        // Agent runs and findings should be gone
377        let agent_runs = get_agent_runs_for_run(&conn, "run1").unwrap();
378        assert!(agent_runs.is_empty());
379
380        let findings = get_findings_for_agent_run(&conn, ar_id).unwrap();
381        assert!(findings.is_empty());
382    }
383}