Skip to main content

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!("**Duration:** {}\n", format_duration(state.elapsed())));
92
93        // Cost (if tracked)
94        if state.cumulative_cost > 0.0 {
95            content.push_str(&format!("**Cost:** ${:.2}\n", state.cumulative_cost));
96        }
97
98        // Tasks section (read from scratchpad if available)
99        content.push('\n');
100        content.push_str("## Tasks\n\n");
101        if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
102            content.push_str(&tasks);
103        } else {
104            content.push_str("_No scratchpad found._\n");
105        }
106
107        // Events section
108        content.push('\n');
109        content.push_str("## Events\n\n");
110        content.push_str(&self.summarize_events());
111
112        // Final commit section
113        if let Some(commit) = final_commit {
114            content.push('\n');
115            content.push_str("## Final Commit\n\n");
116            content.push_str(commit);
117            content.push('\n');
118        }
119
120        content
121    }
122
123    /// Returns a human-readable status based on termination reason.
124    fn status_text(&self, reason: &TerminationReason) -> &'static str {
125        match reason {
126            TerminationReason::CompletionPromise => "Completed successfully",
127            TerminationReason::MaxIterations => "Stopped: max iterations reached",
128            TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
129            TerminationReason::MaxCost => "Stopped: max cost exceeded",
130            TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
131            TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
132            TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
133            TerminationReason::Stopped => "Stopped manually",
134            TerminationReason::Interrupted => "Interrupted by signal",
135        }
136    }
137
138    /// Extracts task lines from the scratchpad file.
139    ///
140    /// Looks for lines matching `- [ ]`, `- [x]`, or `- [~]` patterns.
141    fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
142        let content = fs::read_to_string(scratchpad_path).ok()?;
143        let mut tasks = String::new();
144
145        for line in content.lines() {
146            let trimmed = line.trim();
147            if trimmed.starts_with("- [ ]")
148                || trimmed.starts_with("- [x]")
149                || trimmed.starts_with("- [~]")
150            {
151                tasks.push_str(trimmed);
152                tasks.push('\n');
153            }
154        }
155
156        if tasks.is_empty() {
157            None
158        } else {
159            Some(tasks)
160        }
161    }
162
163    /// Summarizes events from the event history file.
164    fn summarize_events(&self) -> String {
165        let history = EventHistory::default_path();
166
167        let records = match history.read_all() {
168            Ok(r) => r,
169            Err(_) => return "_No event history found._\n".to_string(),
170        };
171
172        if records.is_empty() {
173            return "_No events recorded._\n".to_string();
174        }
175
176        // Count events by topic
177        let mut topic_counts: HashMap<String, usize> = HashMap::new();
178        for record in &records {
179            *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
180        }
181
182        let mut summary = format!("- {} total events\n", records.len());
183
184        // Sort by count descending for consistent output
185        let mut sorted: Vec<_> = topic_counts.into_iter().collect();
186        sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
187
188        for (topic, count) in sorted {
189            summary.push_str(&format!("- {} {}\n", count, topic));
190        }
191
192        summary
193    }
194}
195
196/// Formats a duration as human-readable string (e.g., "23m 45s" or "1h 5m 30s").
197fn format_duration(d: Duration) -> String {
198    let total_secs = d.as_secs();
199    let hours = total_secs / 3600;
200    let minutes = (total_secs % 3600) / 60;
201    let seconds = total_secs % 60;
202
203    if hours > 0 {
204        format!("{}h {}m {}s", hours, minutes, seconds)
205    } else if minutes > 0 {
206        format!("{}m {}s", minutes, seconds)
207    } else {
208        format!("{}s", seconds)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::time::Instant;
216    use tempfile::TempDir;
217
218    fn test_state() -> LoopState {
219        LoopState {
220            iteration: 12,
221            consecutive_failures: 0,
222            cumulative_cost: 1.50,
223            started_at: Instant::now(),
224            last_hat: None,
225            consecutive_blocked: 0,
226            last_blocked_hat: None,
227            task_block_counts: std::collections::HashMap::new(),
228            abandoned_tasks: Vec::new(),
229            abandoned_task_redispatches: 0,
230            completion_confirmations: 0,
231            consecutive_malformed_events: 0,
232        }
233    }
234
235    #[test]
236    fn test_status_text() {
237        let writer = SummaryWriter::default();
238
239        assert_eq!(
240            writer.status_text(&TerminationReason::CompletionPromise),
241            "Completed successfully"
242        );
243        assert_eq!(
244            writer.status_text(&TerminationReason::MaxIterations),
245            "Stopped: max iterations reached"
246        );
247        assert_eq!(
248            writer.status_text(&TerminationReason::ConsecutiveFailures),
249            "Failed: too many consecutive failures"
250        );
251        assert_eq!(
252            writer.status_text(&TerminationReason::Interrupted),
253            "Interrupted by signal"
254        );
255    }
256
257    #[test]
258    fn test_format_duration() {
259        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
260        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
261        assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
262    }
263
264    #[test]
265    fn test_extract_tasks() {
266        let tmp = TempDir::new().unwrap();
267        let scratchpad = tmp.path().join("scratchpad.md");
268
269        let content = r"# Tasks
270
271Some intro text.
272
273- [x] Implement feature A
274- [ ] Implement feature B
275- [~] Feature C (cancelled: not needed)
276
277## Notes
278
279More text here.
280";
281        fs::write(&scratchpad, content).unwrap();
282
283        let writer = SummaryWriter::default();
284        let tasks = writer.extract_tasks(&scratchpad).unwrap();
285
286        assert!(tasks.contains("- [x] Implement feature A"));
287        assert!(tasks.contains("- [ ] Implement feature B"));
288        assert!(tasks.contains("- [~] Feature C"));
289    }
290
291    #[test]
292    fn test_generate_content_basic() {
293        let writer = SummaryWriter::default();
294        let state = test_state();
295
296        let content = writer.generate_content(
297            &TerminationReason::CompletionPromise,
298            &state,
299            None,
300            Some("abc1234: feat(auth): add tokens"),
301        );
302
303        assert!(content.contains("# Loop Summary"));
304        assert!(content.contains("**Status:** Completed successfully"));
305        assert!(content.contains("**Iterations:** 12"));
306        assert!(content.contains("**Cost:** $1.50"));
307        assert!(content.contains("## Tasks"));
308        assert!(content.contains("## Events"));
309        assert!(content.contains("## Final Commit"));
310        assert!(content.contains("abc1234: feat(auth): add tokens"));
311    }
312
313    #[test]
314    fn test_write_creates_directory() {
315        let tmp = TempDir::new().unwrap();
316        let path = tmp.path().join("nested/dir/summary.md");
317
318        let writer = SummaryWriter::new(&path);
319        let state = test_state();
320
321        writer
322            .write(&TerminationReason::CompletionPromise, &state, None, None)
323            .unwrap();
324
325        assert!(path.exists());
326        let content = fs::read_to_string(path).unwrap();
327        assert!(content.contains("# Loop Summary"));
328    }
329}