1use std::fmt::Write as _;
2
3use anyhow::{Context, Result};
4use serde::Serialize;
5
6use super::{GlobalOpts, ReportArgs};
7use crate::{
8 db::{self, AgentRun, Run, graph as db_graph},
9 pipeline::graph::DependencyGraph,
10};
11
12#[allow(clippy::unused_async)]
13pub async fn run(args: ReportArgs, _global: &GlobalOpts) -> Result<()> {
14 let project_dir = std::env::current_dir().context("getting current directory")?;
15 let db_path = project_dir.join(".oven").join("oven.db");
16 let conn = db::open(&db_path)?;
17
18 if args.graph {
19 return show_graph(&conn, args.json);
20 }
21
22 if args.all {
23 let runs = db::runs::get_all_runs(&conn)?;
24 if runs.is_empty() {
25 println!("no runs found");
26 return Ok(());
27 }
28 if args.json {
29 let reports = runs
30 .iter()
31 .map(|r| -> Result<_> {
32 let agents = db::agent_runs::get_agent_runs_for_run(&conn, &r.id)
33 .context("fetching agent runs")?;
34 Ok(RunReport::from_run(r, &agents))
35 })
36 .collect::<Result<Vec<_>>>()?;
37 println!("{}", serde_json::to_string_pretty(&reports)?);
38 } else {
39 print_runs_table(&runs);
40 }
41 return Ok(());
42 }
43
44 let run = if let Some(ref run_id) = args.run_id {
45 db::runs::get_run(&conn, run_id)?.with_context(|| format!("run {run_id} not found"))?
46 } else {
47 db::runs::get_latest_run(&conn)?.context("no runs found")?
48 };
49
50 let agent_runs = db::agent_runs::get_agent_runs_for_run(&conn, &run.id)?;
51
52 if args.json {
53 let report = RunReport::from_run(&run, &agent_runs);
54 println!("{}", serde_json::to_string_pretty(&report)?);
55 } else {
56 print_run_report(&run, &agent_runs);
57 }
58
59 Ok(())
60}
61
62fn print_runs_table(runs: &[Run]) {
63 println!("{:<10} {:<8} {:<12} {:>8}", "Run", "Issue", "Status", "Cost");
64 println!("{}", "-".repeat(42));
65 for run in runs {
66 println!("{:<10} #{:<7} {:<12} ${:.2}", run.id, run.issue_number, run.status, run.cost_usd);
67 }
68}
69
70fn print_run_report(run: &Run, agent_runs: &[AgentRun]) {
71 println!("Run {} - Issue #{}", run.id, run.issue_number);
72 println!("Status: {}", run.status);
73
74 if let Some(start) = run.started_at.get(..19) {
75 println!("Started: {start}");
76 }
77 if let Some(ref end) = run.finished_at {
78 println!("Finished: {}", end.get(..19).unwrap_or(end));
79 }
80
81 println!("Total cost: ${:.2}", run.cost_usd);
82
83 if let Some(ref err) = run.error_message {
84 println!("Error: {err}");
85 }
86
87 if !agent_runs.is_empty() {
88 println!();
89 println!("Agents:");
90 for ar in agent_runs {
91 let mut line = format!(" {:<14} ${:.2} {:>3} turns", ar.agent, ar.cost_usd, ar.turns);
92 let _ = write!(line, " {}", ar.status);
93 println!("{line}");
94 }
95 }
96}
97
98fn show_graph(conn: &rusqlite::Connection, json: bool) -> Result<()> {
99 let Some(session_id) = db_graph::get_active_session(conn)? else {
100 println!("no active dependency graph found");
101 return Ok(());
102 };
103
104 if json {
105 let nodes = db_graph::get_nodes(conn, &session_id)?;
106 let edges = db_graph::get_edges(conn, &session_id)?;
107 let report = GraphReport {
108 session_id,
109 nodes: nodes
110 .iter()
111 .map(|n| GraphNodeReport {
112 issue_number: n.issue_number,
113 title: n.title.clone(),
114 state: n.state.to_string(),
115 area: n.area.clone(),
116 pr_number: n.pr_number,
117 run_id: n.run_id.clone(),
118 })
119 .collect(),
120 edges: edges
121 .iter()
122 .map(|(from, to)| GraphEdgeReport { from: *from, to: *to })
123 .collect(),
124 };
125 println!("{}", serde_json::to_string_pretty(&report)?);
126 } else {
127 let graph = DependencyGraph::from_db(conn, &session_id)?;
128 println!("Dependency Graph (session {session_id})");
129 println!();
130 for line in graph.display_layered() {
131 println!("{line}");
132 }
133 }
134
135 Ok(())
136}
137
138#[derive(Serialize)]
139struct GraphReport {
140 session_id: String,
141 nodes: Vec<GraphNodeReport>,
142 edges: Vec<GraphEdgeReport>,
143}
144
145#[derive(Serialize)]
146struct GraphNodeReport {
147 issue_number: u32,
148 title: String,
149 state: String,
150 area: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pr_number: Option<u32>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 run_id: Option<String>,
155}
156
157#[derive(Serialize)]
158struct GraphEdgeReport {
159 from: u32,
160 to: u32,
161}
162
163#[derive(Serialize)]
165struct RunReport {
166 id: String,
167 issue_number: u32,
168 status: String,
169 cost_usd: f64,
170 started_at: String,
171 finished_at: Option<String>,
172 error_message: Option<String>,
173 agents: Vec<AgentRunReport>,
174}
175
176#[derive(Serialize)]
177struct AgentRunReport {
178 agent: String,
179 cycle: u32,
180 status: String,
181 cost_usd: f64,
182 turns: u32,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 raw_output: Option<String>,
185}
186
187impl RunReport {
188 fn from_run(run: &Run, agent_runs: &[AgentRun]) -> Self {
189 Self {
190 id: run.id.clone(),
191 issue_number: run.issue_number,
192 status: run.status.to_string(),
193 cost_usd: run.cost_usd,
194 started_at: run.started_at.clone(),
195 finished_at: run.finished_at.clone(),
196 error_message: run.error_message.clone(),
197 agents: agent_runs
198 .iter()
199 .map(|ar| AgentRunReport {
200 agent: ar.agent.clone(),
201 cycle: ar.cycle,
202 status: ar.status.clone(),
203 cost_usd: ar.cost_usd,
204 turns: ar.turns,
205 raw_output: ar.raw_output.clone(),
206 })
207 .collect(),
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::db::RunStatus;
216
217 fn sample_run() -> Run {
218 Run {
219 id: "abcd1234".to_string(),
220 issue_number: 42,
221 status: RunStatus::Complete,
222 pr_number: Some(99),
223 branch: Some("oven/issue-42-abc".to_string()),
224 worktree_path: None,
225 cost_usd: 4.23,
226 auto_merge: false,
227 started_at: "2026-03-12T10:00:00".to_string(),
228 finished_at: Some("2026-03-12T10:08:32".to_string()),
229 error_message: None,
230 complexity: "full".to_string(),
231 issue_source: "github".to_string(),
232 }
233 }
234
235 fn sample_agent_runs() -> Vec<AgentRun> {
236 vec![
237 AgentRun {
238 id: 1,
239 run_id: "abcd1234".to_string(),
240 agent: "implementer".to_string(),
241 cycle: 1,
242 status: "complete".to_string(),
243 cost_usd: 2.10,
244 turns: 12,
245 started_at: "2026-03-12T10:00:00".to_string(),
246 finished_at: Some("2026-03-12T10:03:15".to_string()),
247 output_summary: None,
248 error_message: None,
249 raw_output: None,
250 },
251 AgentRun {
252 id: 2,
253 run_id: "abcd1234".to_string(),
254 agent: "reviewer".to_string(),
255 cycle: 1,
256 status: "complete".to_string(),
257 cost_usd: 0.85,
258 turns: 8,
259 started_at: "2026-03-12T10:03:15".to_string(),
260 finished_at: Some("2026-03-12T10:04:57".to_string()),
261 output_summary: None,
262 error_message: None,
263 raw_output: None,
264 },
265 ]
266 }
267
268 #[test]
269 fn run_report_serializes_to_json() {
270 let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
271 let json = serde_json::to_string_pretty(&report).unwrap();
272 assert!(json.contains("abcd1234"));
273 assert!(json.contains("implementer"));
274 assert!(json.contains("reviewer"));
275 assert!(json.contains("4.23"));
276 }
277
278 #[test]
279 fn run_report_includes_all_agents() {
280 let report = RunReport::from_run(&sample_run(), &sample_agent_runs());
281 assert_eq!(report.agents.len(), 2);
282 }
283
284 #[test]
285 fn empty_agent_runs_produces_valid_report() {
286 let report = RunReport::from_run(&sample_run(), &[]);
287 let json = serde_json::to_string(&report).unwrap();
288 assert!(json.contains("\"agents\":[]"));
289 }
290
291 #[test]
292 fn print_run_report_captures_output() {
293 let run = sample_run();
295 let agents = sample_agent_runs();
296 print_run_report(&run, &agents);
297 }
298
299 #[test]
300 fn print_run_report_with_error() {
301 let mut run = sample_run();
302 run.error_message = Some("something broke".to_string());
303 run.status = RunStatus::Failed;
304 print_run_report(&run, &[]);
305 }
306
307 #[test]
308 fn print_runs_table_formats_rows() {
309 let runs = vec![
310 sample_run(),
311 Run {
312 id: "efgh5678".to_string(),
313 issue_number: 99,
314 status: RunStatus::Failed,
315 pr_number: None,
316 branch: None,
317 worktree_path: None,
318 cost_usd: 12.50,
319 auto_merge: false,
320 started_at: "2026-03-13T10:00:00".to_string(),
321 finished_at: None,
322 error_message: Some("budget exceeded".to_string()),
323 complexity: "full".to_string(),
324 issue_source: "github".to_string(),
325 },
326 ];
327 print_runs_table(&runs);
328 }
329
330 #[test]
331 fn run_report_from_run_maps_all_fields() {
332 let run = Run {
333 id: "test0001".to_string(),
334 issue_number: 7,
335 status: RunStatus::Failed,
336 pr_number: Some(55),
337 branch: Some("oven/issue-7-abc".to_string()),
338 worktree_path: None,
339 cost_usd: 18.75,
340 auto_merge: true,
341 started_at: "2026-03-12T10:00:00".to_string(),
342 finished_at: Some("2026-03-12T10:30:00".to_string()),
343 error_message: Some("cost exceeded".to_string()),
344 complexity: "full".to_string(),
345 issue_source: "github".to_string(),
346 };
347 let agents = vec![AgentRun {
348 id: 1,
349 run_id: "test0001".to_string(),
350 agent: "implementer".to_string(),
351 cycle: 1,
352 status: "failed".to_string(),
353 cost_usd: 18.75,
354 turns: 50,
355 started_at: "2026-03-12T10:00:00".to_string(),
356 finished_at: Some("2026-03-12T10:30:00".to_string()),
357 output_summary: None,
358 error_message: Some("budget".to_string()),
359 raw_output: None,
360 }];
361 let report = RunReport::from_run(&run, &agents);
362 assert_eq!(report.id, "test0001");
363 assert_eq!(report.status, "failed");
364 assert_eq!(report.error_message.as_deref(), Some("cost exceeded"));
365 assert_eq!(report.agents.len(), 1);
366 assert_eq!(report.agents[0].turns, 50);
367 }
368}