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 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 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 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 conn.execute("DELETE FROM runs WHERE id = ?1", params!["run1"]).unwrap();
375
376 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}