ralph_core/
summary_writer.rs

1//! Summary file generation for loop termination.
2//!
3//! Per spec: "On termination, the orchestrator writes `.agent/summary.md`"
4//! with status, iterations, duration, task list, events summary, and commit info.
5
6use crate::event_logger::EventHistory;
7use crate::event_loop::{LoopState, TerminationReason};
8use std::collections::HashMap;
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14/// Writes the loop summary file on termination.
15///
16/// Per spec section "Exit Summary":
17/// ```markdown
18/// # Loop Summary
19///
20/// **Status:** Completed successfully
21/// **Iterations:** 12
22/// **Duration:** 23m 45s
23///
24/// ## Tasks
25/// - [x] Add refresh token support
26/// - [x] Update login endpoint
27/// - [~] Add rate limiting (cancelled: out of scope)
28///
29/// ## Events
30/// - 12 total events
31/// - 6 build.task
32/// - 5 build.done
33/// - 1 build.blocked
34///
35/// ## Final Commit
36/// abc1234: feat(auth): complete auth overhaul
37/// ```
38#[derive(Debug)]
39pub struct SummaryWriter {
40    path: PathBuf,
41}
42
43impl Default for SummaryWriter {
44    fn default() -> Self {
45        Self::new(".agent/summary.md")
46    }
47}
48
49impl SummaryWriter {
50    /// Creates a new summary writer with the given path.
51    pub fn new(path: impl Into<PathBuf>) -> Self {
52        Self { path: path.into() }
53    }
54
55    /// Writes the summary file based on loop state and termination reason.
56    ///
57    /// This is called by the orchestrator when the loop terminates.
58    pub fn write(
59        &self,
60        reason: &TerminationReason,
61        state: &LoopState,
62        scratchpad_path: Option<&Path>,
63        final_commit: Option<&str>,
64    ) -> io::Result<()> {
65        // Ensure parent directory exists
66        if let Some(parent) = self.path.parent() {
67            fs::create_dir_all(parent)?;
68        }
69
70        let content = self.generate_content(reason, state, scratchpad_path, final_commit);
71        fs::write(&self.path, content)
72    }
73
74    /// Generates the markdown content for the summary.
75    fn generate_content(
76        &self,
77        reason: &TerminationReason,
78        state: &LoopState,
79        scratchpad_path: Option<&Path>,
80        final_commit: Option<&str>,
81    ) -> String {
82        let mut content = String::new();
83
84        // Header
85        content.push_str("# Loop Summary\n\n");
86
87        // Status
88        let status = self.status_text(reason);
89        content.push_str(&format!("**Status:** {status}\n"));
90        content.push_str(&format!("**Iterations:** {}\n", state.iteration));
91        content.push_str(&format!(
92            "**Duration:** {}\n",
93            format_duration(state.elapsed())
94        ));
95
96        // Cost (if tracked)
97        if state.cumulative_cost > 0.0 {
98            content.push_str(&format!("**Cost:** ${:.2}\n", state.cumulative_cost));
99        }
100
101        // Tasks section (read from scratchpad if available)
102        content.push('\n');
103        content.push_str("## Tasks\n\n");
104        if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
105            content.push_str(&tasks);
106        } else {
107            content.push_str("_No scratchpad found._\n");
108        }
109
110        // Events section
111        content.push('\n');
112        content.push_str("## Events\n\n");
113        content.push_str(&self.summarize_events());
114
115        // Final commit section
116        if let Some(commit) = final_commit {
117            content.push('\n');
118            content.push_str("## Final Commit\n\n");
119            content.push_str(commit);
120            content.push('\n');
121        }
122
123        content
124    }
125
126    /// Returns a human-readable status based on termination reason.
127    fn status_text(&self, reason: &TerminationReason) -> &'static str {
128        match reason {
129            TerminationReason::CompletionPromise => "Completed successfully",
130            TerminationReason::MaxIterations => "Stopped: max iterations reached",
131            TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
132            TerminationReason::MaxCost => "Stopped: max cost exceeded",
133            TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
134            TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
135            TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
136            TerminationReason::Stopped => "Stopped manually",
137            TerminationReason::Interrupted => "Interrupted by signal",
138        }
139    }
140
141    /// Extracts task lines from the scratchpad file.
142    ///
143    /// Looks for lines matching `- [ ]`, `- [x]`, or `- [~]` patterns.
144    fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
145        let content = fs::read_to_string(scratchpad_path).ok()?;
146        let mut tasks = String::new();
147
148        for line in content.lines() {
149            let trimmed = line.trim();
150            if trimmed.starts_with("- [ ]")
151                || trimmed.starts_with("- [x]")
152                || trimmed.starts_with("- [~]")
153            {
154                tasks.push_str(trimmed);
155                tasks.push('\n');
156            }
157        }
158
159        if tasks.is_empty() { None } else { Some(tasks) }
160    }
161
162    /// Summarizes events from the event history file.
163    fn summarize_events(&self) -> String {
164        let history = EventHistory::default_path();
165
166        let records = match history.read_all() {
167            Ok(r) => r,
168            Err(_) => return "_No event history found._\n".to_string(),
169        };
170
171        if records.is_empty() {
172            return "_No events recorded._\n".to_string();
173        }
174
175        // Count events by topic
176        let mut topic_counts: HashMap<String, usize> = HashMap::new();
177        for record in &records {
178            *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
179        }
180
181        let mut summary = format!("- {} total events\n", records.len());
182
183        // Sort by count descending for consistent output
184        let mut sorted: Vec<_> = topic_counts.into_iter().collect();
185        sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
186
187        for (topic, count) in sorted {
188            summary.push_str(&format!("- {} {}\n", count, topic));
189        }
190
191        summary
192    }
193}
194
195/// Formats a duration as human-readable string (e.g., "23m 45s" or "1h 5m 30s").
196fn format_duration(d: Duration) -> String {
197    let total_secs = d.as_secs();
198    let hours = total_secs / 3600;
199    let minutes = (total_secs % 3600) / 60;
200    let seconds = total_secs % 60;
201
202    if hours > 0 {
203        format!("{}h {}m {}s", hours, minutes, seconds)
204    } else if minutes > 0 {
205        format!("{}m {}s", minutes, seconds)
206    } else {
207        format!("{}s", seconds)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::time::Instant;
215    use tempfile::TempDir;
216
217    fn test_state() -> LoopState {
218        LoopState {
219            iteration: 12,
220            consecutive_failures: 0,
221            cumulative_cost: 1.50,
222            started_at: Instant::now(),
223            last_hat: None,
224            consecutive_blocked: 0,
225            last_blocked_hat: None,
226            task_block_counts: std::collections::HashMap::new(),
227            abandoned_tasks: Vec::new(),
228            abandoned_task_redispatches: 0,
229            completion_confirmations: 0,
230            consecutive_malformed_events: 0,
231        }
232    }
233
234    #[test]
235    fn test_status_text() {
236        let writer = SummaryWriter::default();
237
238        assert_eq!(
239            writer.status_text(&TerminationReason::CompletionPromise),
240            "Completed successfully"
241        );
242        assert_eq!(
243            writer.status_text(&TerminationReason::MaxIterations),
244            "Stopped: max iterations reached"
245        );
246        assert_eq!(
247            writer.status_text(&TerminationReason::ConsecutiveFailures),
248            "Failed: too many consecutive failures"
249        );
250        assert_eq!(
251            writer.status_text(&TerminationReason::Interrupted),
252            "Interrupted by signal"
253        );
254    }
255
256    #[test]
257    fn test_format_duration() {
258        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
259        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
260        assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
261    }
262
263    #[test]
264    fn test_extract_tasks() {
265        let tmp = TempDir::new().unwrap();
266        let scratchpad = tmp.path().join("scratchpad.md");
267
268        let content = r"# Tasks
269
270Some intro text.
271
272- [x] Implement feature A
273- [ ] Implement feature B
274- [~] Feature C (cancelled: not needed)
275
276## Notes
277
278More text here.
279";
280        fs::write(&scratchpad, content).unwrap();
281
282        let writer = SummaryWriter::default();
283        let tasks = writer.extract_tasks(&scratchpad).unwrap();
284
285        assert!(tasks.contains("- [x] Implement feature A"));
286        assert!(tasks.contains("- [ ] Implement feature B"));
287        assert!(tasks.contains("- [~] Feature C"));
288    }
289
290    #[test]
291    fn test_generate_content_basic() {
292        let writer = SummaryWriter::default();
293        let state = test_state();
294
295        let content = writer.generate_content(
296            &TerminationReason::CompletionPromise,
297            &state,
298            None,
299            Some("abc1234: feat(auth): add tokens"),
300        );
301
302        assert!(content.contains("# Loop Summary"));
303        assert!(content.contains("**Status:** Completed successfully"));
304        assert!(content.contains("**Iterations:** 12"));
305        assert!(content.contains("**Cost:** $1.50"));
306        assert!(content.contains("## Tasks"));
307        assert!(content.contains("## Events"));
308        assert!(content.contains("## Final Commit"));
309        assert!(content.contains("abc1234: feat(auth): add tokens"));
310    }
311
312    #[test]
313    fn test_write_creates_directory() {
314        let tmp = TempDir::new().unwrap();
315        let path = tmp.path().join("nested/dir/summary.md");
316
317        let writer = SummaryWriter::new(&path);
318        let state = test_state();
319
320        writer
321            .write(&TerminationReason::CompletionPromise, &state, None, None)
322            .unwrap();
323
324        assert!(path.exists());
325        let content = fs::read_to_string(path).unwrap();
326        assert!(content.contains("# Loop Summary"));
327    }
328}