1use crate::cli::init_store;
2use crate::cli::ndjson;
3use crate::home;
4use crate::store::{TaskStatus, TaskStore};
5use anyhow::Context;
6use serde::Serialize;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10pub async fn export(
11 task_id: &str,
12 attempt: Option<u32>,
13 format: ExportFormat,
14) -> anyhow::Result<()> {
15 let store = Arc::new(init_store().await?);
16
17 let (internal_id, repo) = parse_task_id(task_id, &store).await?;
18 let task = store.get(internal_id).await.context("task not found")?;
19
20 let attempt = match attempt {
21 Some(n) => n,
22 None => {
23 let runs = store.get_runs(internal_id).await?;
24 if runs.is_empty() {
25 anyhow::bail!("no attempts found for task");
26 }
27 let max_attempt = runs.iter().map(|r| r.attempt).max().unwrap_or(1);
28 max_attempt as u32
29 }
30 };
31
32 let attempt_dir = home::task_attempt_dir(&repo, task_id, attempt)?;
33 let output_path = attempt_dir.join("output.json");
34
35 if !output_path.exists() {
36 anyhow::bail!("attempt {} not found (no output file)", attempt);
37 }
38
39 let content = std::fs::read_to_string(&output_path)?;
40
41 match format {
42 ExportFormat::Markdown => export_markdown(&task, attempt, &content, &store, &repo).await,
43 ExportFormat::Json => export_json(&task, attempt, &content),
44 ExportFormat::Raw => {
45 println!("{}", content);
46 Ok(())
47 }
48 }
49}
50
51struct ParsedOutput {
53 files_changed: Vec<String>,
55 files_read: Vec<String>,
57 tool_counts: BTreeMap<String, usize>,
59 git_commands: Vec<String>,
61 key_messages: Vec<String>,
63}
64
65impl ParsedOutput {
66 fn is_empty(&self) -> bool {
67 self.files_changed.is_empty()
68 && self.files_read.is_empty()
69 && self.tool_counts.is_empty()
70 && self.git_commands.is_empty()
71 && self.key_messages.is_empty()
72 }
73}
74
75fn parse_output(content: &str) -> ParsedOutput {
82 let mut files_changed: std::collections::BTreeSet<String> = Default::default();
83 let mut files_read: std::collections::BTreeSet<String> = Default::default();
84 let mut tool_counts: BTreeMap<String, usize> = Default::default();
85 let mut git_commands: Vec<String> = Vec::new();
86 let mut key_messages: Vec<String> = Vec::new();
87
88 for line in content.lines() {
89 let trimmed = line.trim();
90 if trimmed.is_empty() {
91 continue;
92 }
93 let v: serde_json::Value = match serde_json::from_str(trimmed) {
94 Ok(v) => v,
95 Err(_) => continue,
96 };
97
98 let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
99
100 match event_type {
101 "assistant" => {
103 if let Some(text) = extract_claude_text(&v) {
104 if text.len() > 40 && key_messages.len() < 10 {
105 key_messages.push(first_sentence(&text, 160));
106 }
107 }
108 }
109
110 "tool_use" if v.get("tool").is_some() => {
111 if let Some(tool) = v.get("tool") {
113 let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?");
114 *tool_counts.entry(name.to_string()).or_default() += 1;
115 let input = tool.get("input");
116 record_file_or_command(
117 name,
118 input,
119 &mut files_changed,
120 &mut files_read,
121 &mut git_commands,
122 );
123 }
124 }
125
126 "text" => {
128 let text = v
129 .get("part")
130 .and_then(|p| p.get("text"))
131 .and_then(|t| t.as_str())
132 .or_else(|| v.get("text").and_then(|t| t.as_str()))
133 .unwrap_or("");
134 if text.len() > 40 && key_messages.len() < 10 {
135 key_messages.push(first_sentence(text, 160));
136 }
137 }
138
139 "tool_use" if v.get("part").is_some() => {
140 if let Some(part) = v.get("part") {
142 let name = part.get("tool").and_then(|t| t.as_str()).unwrap_or("?");
143 *tool_counts.entry(name.to_string()).or_default() += 1;
144 let input = part.get("state").and_then(|s| s.get("input"));
145 record_file_or_command(
146 name,
147 input,
148 &mut files_changed,
149 &mut files_read,
150 &mut git_commands,
151 );
152 }
153 }
154
155 "item.completed" => {
157 if let Some(item) = v.get("item") {
158 let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
159 match item_type {
160 "agent_message" => {
161 let text = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
162 if text.len() > 40 && key_messages.len() < 10 {
163 key_messages.push(first_sentence(text, 160));
164 }
165 }
166 "command_execution" => {
167 let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
168 *tool_counts.entry("bash".to_string()).or_default() += 1;
169 let first_line = cmd.lines().next().unwrap_or("").trim();
170 if (first_line.starts_with("git ") || first_line == "git")
171 && git_commands.len() < 20
172 {
173 git_commands.push(truncate_str(first_line, 120));
174 }
175 }
176 _ => {}
177 }
178 }
179 }
180
181 _ => {}
182 }
183 }
184
185 ParsedOutput {
186 files_changed: files_changed
187 .into_iter()
188 .chain(ndjson::extract_files_changed(content))
189 .collect::<std::collections::BTreeSet<_>>()
190 .into_iter()
191 .collect(),
192 files_read: files_read.into_iter().collect(),
193 tool_counts,
194 git_commands,
195 key_messages,
196 }
197}
198
199fn record_file_or_command(
201 name: &str,
202 input: Option<&serde_json::Value>,
203 files_changed: &mut std::collections::BTreeSet<String>,
204 files_read: &mut std::collections::BTreeSet<String>,
205 git_commands: &mut Vec<String>,
206) {
207 let Some(input) = input else { return };
208 match name {
209 "Write" | "write" | "NotebookEdit" => {
210 if let Some(p) = str_field(input, "file_path") {
211 files_changed.insert(p);
212 }
213 }
214 "Edit" | "edit" | "MultiEdit" => {
215 if let Some(p) = str_field(input, "file_path") {
216 files_changed.insert(p);
217 }
218 }
219 "Read" | "read" => {
220 if let Some(p) = str_field(input, "file_path") {
221 files_read.insert(p);
222 }
223 }
224 "Bash" | "bash" => {
225 if let Some(cmd) = str_field(input, "command") {
226 let first_line = cmd.lines().next().unwrap_or("").trim();
227 if (first_line.starts_with("git ") || first_line == "git")
228 && git_commands.len() < 20
229 {
230 git_commands.push(truncate_str(first_line, 120));
231 }
232 }
233 }
234 _ => {}
235 }
236}
237
238async fn export_markdown(
239 task: &crate::store::Task,
240 attempt: u32,
241 content: &str,
242 store: &Arc<TaskStore>,
243 _repo: &str,
244) -> anyhow::Result<()> {
245 let runs = store.get_runs(task.id).await?;
246 let attempt_run = runs.iter().find(|r| r.attempt == attempt as i32);
247
248 let agent = task.agent.as_deref().unwrap_or("unknown");
249 let model = task.model.as_deref().unwrap_or("-");
250
251 let duration = attempt_run.map(|r| r.duration_secs).unwrap_or(0.0);
252 let duration_str = format_duration(duration);
253
254 let input_tokens = task.input_tokens;
255 let output_tokens = task.output_tokens;
256
257 let cost = task.total_cost_usd;
258
259 println!(
260 "# Session: {} (Attempt {})",
261 task_id_display(&task.id, &task.external_id),
262 attempt
263 );
264 println!();
265 println!("**Agent**: {} ({})", agent, model);
266 println!("**Duration**: {}", duration_str);
267 println!(
268 "**Tokens**: {} in / {} out",
269 format_num(input_tokens),
270 format_num(output_tokens)
271 );
272 if cost > 0.0 {
273 println!("**Cost**: ${:.4}", cost);
274 }
275 println!("---");
276
277 if content.trim().is_empty() {
278 println!("(no output captured)");
279 return Ok(());
280 }
281
282 let parsed = parse_output(content);
284 if !parsed.is_empty() {
285 println!();
286 println!("## Summary");
287
288 if !parsed.files_changed.is_empty() {
289 println!();
290 println!("### Files Changed");
291 for f in &parsed.files_changed {
292 println!("- `{}`", f);
293 }
294 }
295
296 if !parsed.tool_counts.is_empty() {
297 println!();
298 println!("### Tool Usage");
299 let mut counts: Vec<(&String, &usize)> = parsed.tool_counts.iter().collect();
301 counts.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
302 for (tool, count) in &counts {
303 println!("- **{}**: {}", tool, count);
304 }
305 }
306
307 if !parsed.git_commands.is_empty() {
308 println!();
309 println!("### Git Context");
310 let mut seen = std::collections::HashSet::new();
312 for cmd in &parsed.git_commands {
313 if seen.insert(cmd.as_str()) {
314 println!("- `{}`", cmd);
315 }
316 }
317 }
318
319 if !parsed.key_messages.is_empty() {
320 println!();
321 println!("### Key Decisions");
322 for msg in &parsed.key_messages {
323 println!("- {}", msg);
324 }
325 }
326 }
327
328 println!();
330 println!("## Output");
331 println!();
332
333 let mut formatter = ndjson::Formatter::new(false);
334 for line in content.lines() {
335 ndjson::print(&formatter.format(line));
336 }
337
338 let summary = &task.summary;
340 if !summary.is_empty() {
341 println!();
342 println!("---");
343 println!();
344 println!("## Result");
345 println!();
346
347 let status = if task.status == TaskStatus::Done {
348 "success"
349 } else if task.status == TaskStatus::Blocked {
350 "blocked"
351 } else {
352 "incomplete"
353 };
354
355 println!("**Status**: {}", status);
356 println!("**Summary**: {}", summary);
357 }
358
359 let error = &task.last_error;
360 if !error.is_empty() {
361 println!();
362 println!("**Error**: {}", error);
363 }
364
365 Ok(())
366}
367
368fn export_json(task: &crate::store::Task, attempt: u32, content: &str) -> anyhow::Result<()> {
369 let output_lines: Vec<serde_json::Value> = content
370 .lines()
371 .filter_map(|line| {
372 let trimmed = line.trim();
373 if trimmed.is_empty() {
374 return None;
375 }
376 serde_json::from_str(trimmed).ok()
377 })
378 .collect();
379
380 let parsed = parse_output(content);
381
382 #[derive(Serialize)]
383 struct SessionExport {
384 task_id: i64,
385 external_id: Option<String>,
386 attempt: u32,
387 agent: Option<String>,
388 model: Option<String>,
389 input_tokens: i64,
390 output_tokens: i64,
391 total_cost_usd: f64,
392 summary: String,
393 last_error: String,
394 status: String,
395 files_changed: Vec<String>,
396 files_read: Vec<String>,
397 tool_counts: BTreeMap<String, usize>,
398 git_commands: Vec<String>,
399 key_messages: Vec<String>,
400 output: Vec<serde_json::Value>,
401 }
402
403 let export = SessionExport {
404 task_id: task.id,
405 external_id: task.external_id.clone(),
406 attempt,
407 agent: task.agent.clone(),
408 model: task.model.clone(),
409 input_tokens: task.input_tokens,
410 output_tokens: task.output_tokens,
411 total_cost_usd: task.total_cost_usd,
412 summary: task.summary.clone(),
413 last_error: task.last_error.clone(),
414 status: task.status.as_str().to_string(),
415 files_changed: parsed.files_changed,
416 files_read: parsed.files_read,
417 tool_counts: parsed.tool_counts,
418 git_commands: parsed.git_commands,
419 key_messages: parsed.key_messages,
420 output: output_lines,
421 };
422
423 println!("{}", serde_json::to_string_pretty(&export)?);
424
425 Ok(())
426}
427
428async fn parse_task_id(task_id: &str, store: &Arc<TaskStore>) -> anyhow::Result<(i64, String)> {
429 if task_id.starts_with("internal:") {
430 let id_part = task_id
431 .strip_prefix("internal:")
432 .unwrap_or(task_id)
433 .parse::<i64>()
434 .context("invalid internal task ID")?;
435 let repo = store
436 .get(id_part)
437 .await
438 .context("internal task not found")?
439 .repo;
440 Ok((id_part, repo))
441 } else {
442 let id: i64 = task_id.parse().context("invalid task ID")?;
443 let repo = crate::config::get_current_repo()
444 .context("no project configured — run `orch init` first")?;
445 Ok((id, repo))
446 }
447}
448
449fn task_id_display(internal_id: &i64, external_id: &Option<String>) -> String {
450 match external_id {
451 Some(ext) => format!("{}:{}", ext, internal_id),
452 None => format!("internal:{}", internal_id),
453 }
454}
455
456fn format_num(n: i64) -> String {
457 let s = n.to_string();
458 let mut result = String::new();
459 for (i, c) in s.chars().rev().enumerate() {
460 if i > 0 && i % 3 == 0 {
461 result.push(',');
462 }
463 result.push(c);
464 }
465 result.chars().rev().collect()
466}
467
468fn format_duration(secs: f64) -> String {
469 if secs < 60.0 {
470 format!("{:.0}s", secs)
471 } else if secs < 3600.0 {
472 let mins = secs / 60.0;
473 if mins < 10.0 {
474 format!("{:.1}m", mins)
475 } else {
476 format!("{:.0}m", mins)
477 }
478 } else {
479 let hours = secs / 3600.0;
480 format!("{:.1}h", hours)
481 }
482}
483
484fn extract_claude_text(v: &serde_json::Value) -> Option<String> {
486 let content = v.get("message")?.get("content")?.as_array()?;
487 let text: String = content
488 .iter()
489 .filter_map(|item| {
490 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
491 item.get("text")
492 .and_then(|t| t.as_str())
493 .map(str::to_string)
494 } else {
495 None
496 }
497 })
498 .collect::<Vec<_>>()
499 .join("");
500 if text.is_empty() {
501 None
502 } else {
503 Some(text)
504 }
505}
506
507fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
509 v.get(key).and_then(|s| s.as_str()).map(str::to_string)
510}
511
512fn first_sentence(s: &str, max: usize) -> String {
514 let sentence_end = s
516 .char_indices()
517 .find(|(_, c)| matches!(c, '.' | '!' | '?'))
518 .map(|(i, _)| i + 1)
519 .unwrap_or(s.len());
520 let candidate = &s[..sentence_end.min(s.len())];
521 truncate_str(candidate.trim(), max)
522}
523
524fn truncate_str(s: &str, max: usize) -> String {
526 for (char_count, (byte_idx, _ch)) in s.char_indices().enumerate() {
527 if char_count >= max {
528 return format!("{}…", &s[..byte_idx]);
529 }
530 }
531 s.to_string()
532}
533
534#[derive(Debug, Clone, Copy, Default)]
535pub enum ExportFormat {
536 #[default]
537 Markdown,
538 Json,
539 Raw,
540}
541
542impl std::str::FromStr for ExportFormat {
543 type Err = String;
544
545 fn from_str(s: &str) -> Result<Self, Self::Err> {
546 match s.to_lowercase().as_str() {
547 "markdown" | "md" => Ok(Self::Markdown),
548 "json" | "js" => Ok(Self::Json),
549 "raw" | "ndjson" => Ok(Self::Raw),
550 _ => Err(format!("unknown format: {}", s)),
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn parse_claude_write_edit() {
561 let content = r#"
562{"type":"tool_use","tool":{"name":"Write","input":{"file_path":"src/lib.rs","content":"..."}}}
563{"type":"tool_use","tool":{"name":"Edit","input":{"file_path":"src/main.rs","old_string":"foo","new_string":"bar"}}}
564{"type":"tool_use","tool":{"name":"Read","input":{"file_path":"Cargo.toml"}}}
565"#;
566 let p = parse_output(content);
567 assert_eq!(p.files_changed, vec!["src/lib.rs", "src/main.rs"]);
568 assert_eq!(p.files_read, vec!["Cargo.toml"]);
569 assert_eq!(p.tool_counts["Write"], 1);
570 assert_eq!(p.tool_counts["Edit"], 1);
571 assert_eq!(p.tool_counts["Read"], 1);
572 }
573
574 #[test]
575 fn parse_claude_git_bash() {
576 let content = r#"
577{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git status"}}}
578{"type":"tool_use","tool":{"name":"Bash","input":{"command":"cargo build"}}}
579{"type":"tool_use","tool":{"name":"Bash","input":{"command":"git commit -m \"fix\""}}}
580"#;
581 let p = parse_output(content);
582 assert_eq!(p.git_commands.len(), 2);
583 assert!(p.git_commands.contains(&"git status".to_string()));
584 assert!(p
585 .git_commands
586 .contains(&"git commit -m \"fix\"".to_string()));
587 assert_eq!(p.tool_counts["Bash"], 3);
588 }
589
590 #[test]
591 fn parse_opencode_tool_use() {
592 let content = r#"
593{"type":"tool_use","part":{"tool":"write","state":{"input":{"file_path":"README.md"}}}}
594{"type":"tool_use","part":{"tool":"bash","state":{"input":{"command":"git diff"}}}}
595"#;
596 let p = parse_output(content);
597 assert_eq!(p.files_changed, vec!["README.md"]);
598 assert_eq!(p.git_commands, vec!["git diff"]);
599 }
600
601 #[test]
602 fn parse_codex_command_execution() {
603 let content = r#"
604{"type":"item.completed","item":{"type":"command_execution","command":"git log --oneline -5"}}
605{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}
606"#;
607 let p = parse_output(content);
608 assert_eq!(p.git_commands, vec!["git log --oneline -5"]);
609 assert_eq!(p.tool_counts["bash"], 2);
610 }
611
612 #[test]
613 fn parse_key_messages_from_assistant() {
614 let content = r#"
615{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"This is a short msg."}]}}
616{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I have analyzed the codebase and found that the main issue is in the authentication module."}]}}
617"#;
618 let p = parse_output(content);
619 assert_eq!(p.key_messages.len(), 1);
621 assert!(p.key_messages[0].contains("authentication"));
622 }
623
624 #[test]
625 fn parse_empty_content() {
626 let p = parse_output("");
627 assert!(p.is_empty());
628 }
629
630 #[test]
631 fn first_sentence_with_period() {
632 let s = "I will fix the bug. Then run tests.";
633 assert_eq!(first_sentence(s, 200), "I will fix the bug.");
634 }
635
636 #[test]
637 fn first_sentence_truncated() {
638 let long = "abcdefghijklmnopqrstuvwxyz".repeat(10);
639 let result = first_sentence(&long, 20);
640 assert!(result.ends_with('…'));
641 }
642
643 #[test]
644 fn format_num_thousands() {
645 assert_eq!(format_num(1234567), "1,234,567");
646 assert_eq!(format_num(999), "999");
647 }
648
649 #[test]
650 fn format_duration_variants() {
651 assert_eq!(format_duration(45.0), "45s");
652 assert_eq!(format_duration(90.0), "1.5m");
653 assert_eq!(format_duration(600.0), "10m");
654 assert_eq!(format_duration(7200.0), "2.0h");
655 }
656}