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