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 `.ralph/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 crate::landing::LandingResult;
9use crate::loop_context::LoopContext;
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16/// Writes the loop summary file on termination.
17///
18/// Per spec section "Exit Summary":
19/// ```markdown
20/// # Loop Summary
21///
22/// **Status:** Completed successfully
23/// **Iterations:** 12
24/// **Duration:** 23m 45s
25///
26/// ## Tasks
27/// - [x] Add refresh token support
28/// - [x] Update login endpoint
29/// - [~] Add rate limiting (cancelled: out of scope)
30///
31/// ## Events
32/// - 12 total events
33/// - 6 build.task
34/// - 5 build.done
35/// - 1 build.blocked
36///
37/// ## Final Commit
38/// abc1234: feat(auth): complete auth overhaul
39/// ```
40#[derive(Debug)]
41pub struct SummaryWriter {
42    path: PathBuf,
43    /// Path to the events file for reading history.
44    /// If None, uses the default path relative to current directory.
45    events_path: Option<PathBuf>,
46}
47
48impl Default for SummaryWriter {
49    fn default() -> Self {
50        Self::new(".ralph/agent/summary.md")
51    }
52}
53
54impl SummaryWriter {
55    /// Creates a new summary writer with the given path.
56    pub fn new(path: impl Into<PathBuf>) -> Self {
57        Self {
58            path: path.into(),
59            events_path: None,
60        }
61    }
62
63    /// Creates a summary writer using paths from a LoopContext.
64    ///
65    /// This ensures the writer outputs to the correct location and reads
66    /// events from the correct events file when running in a worktree
67    /// or other isolated workspace.
68    pub fn from_context(context: &LoopContext) -> Self {
69        Self {
70            path: context.summary_path(),
71            events_path: Some(context.events_path()),
72        }
73    }
74
75    /// Writes the summary file based on loop state and termination reason.
76    ///
77    /// This is called by the orchestrator when the loop terminates.
78    pub fn write(
79        &self,
80        reason: &TerminationReason,
81        state: &LoopState,
82        scratchpad_path: Option<&Path>,
83        final_commit: Option<&str>,
84    ) -> io::Result<()> {
85        self.write_with_landing(reason, state, scratchpad_path, final_commit, None)
86    }
87
88    /// Writes the summary file with optional landing information.
89    ///
90    /// This is called by the orchestrator when the loop terminates with landing.
91    pub fn write_with_landing(
92        &self,
93        reason: &TerminationReason,
94        state: &LoopState,
95        scratchpad_path: Option<&Path>,
96        final_commit: Option<&str>,
97        landing: Option<&LandingResult>,
98    ) -> io::Result<()> {
99        // Ensure parent directory exists
100        if let Some(parent) = self.path.parent() {
101            fs::create_dir_all(parent)?;
102        }
103
104        let content = self.generate_content_with_landing(
105            reason,
106            state,
107            scratchpad_path,
108            final_commit,
109            landing,
110        );
111        fs::write(&self.path, content)
112    }
113
114    /// Generates the markdown content for the summary with optional landing info.
115    fn generate_content_with_landing(
116        &self,
117        reason: &TerminationReason,
118        state: &LoopState,
119        scratchpad_path: Option<&Path>,
120        final_commit: Option<&str>,
121        landing: Option<&LandingResult>,
122    ) -> String {
123        let mut content = String::new();
124
125        // Header
126        content.push_str("# Loop Summary\n\n");
127
128        // Status
129        let status = self.status_text(reason);
130        content.push_str(&format!("**Status:** {status}\n"));
131        content.push_str(&format!("**Iterations:** {}\n", state.iteration));
132        content.push_str(&format!(
133            "**Duration:** {}\n",
134            format_duration(state.elapsed())
135        ));
136
137        // Cost (if tracked)
138        if state.cumulative_cost > 0.0 {
139            content.push_str(&format!("**Est. cost:** ${:.2}\n", state.cumulative_cost));
140        }
141
142        // Tasks section (read from scratchpad if available)
143        content.push('\n');
144        content.push_str("## Tasks\n\n");
145        if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
146            content.push_str(&tasks);
147        } else {
148            content.push_str("_No scratchpad found._\n");
149        }
150
151        // Events section
152        content.push('\n');
153        content.push_str("## Events\n\n");
154        content.push_str(&self.summarize_events());
155
156        // Final commit section
157        if let Some(commit) = final_commit {
158            content.push('\n');
159            content.push_str("## Final Commit\n\n");
160            content.push_str(commit);
161            content.push('\n');
162        }
163
164        // Landing section (if landing was performed)
165        if let Some(landing_result) = landing {
166            content.push('\n');
167            content.push_str("## Landing\n\n");
168
169            if landing_result.committed {
170                content.push_str(&format!(
171                    "- **Auto-committed:** Yes ({})\n",
172                    landing_result.commit_sha.as_deref().unwrap_or("unknown")
173                ));
174            } else {
175                content.push_str("- **Auto-committed:** No (working tree was clean)\n");
176            }
177
178            content.push_str(&format!(
179                "- **Handoff:** `{}`\n",
180                landing_result.handoff_path.display()
181            ));
182
183            if !landing_result.open_tasks.is_empty() {
184                content.push_str(&format!(
185                    "- **Open tasks:** {}\n",
186                    landing_result.open_tasks.len()
187                ));
188            }
189
190            if landing_result.stashes_cleared > 0 {
191                content.push_str(&format!(
192                    "- **Stashes cleared:** {}\n",
193                    landing_result.stashes_cleared
194                ));
195            }
196
197            content.push_str(&format!(
198                "- **Working tree clean:** {}\n",
199                if landing_result.working_tree_clean {
200                    "Yes"
201                } else {
202                    "No"
203                }
204            ));
205        }
206
207        content
208    }
209
210    /// Returns a human-readable status based on termination reason.
211    fn status_text(&self, reason: &TerminationReason) -> &'static str {
212        match reason {
213            TerminationReason::CompletionPromise => "Completed successfully",
214            TerminationReason::MaxIterations => "Stopped: max iterations reached",
215            TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
216            TerminationReason::MaxCost => "Stopped: max cost exceeded",
217            TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
218            TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
219            TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
220            TerminationReason::Stopped => "Stopped manually",
221            TerminationReason::Interrupted => "Interrupted by signal",
222            TerminationReason::ChaosModeComplete => "Chaos mode: exploration complete",
223            TerminationReason::ChaosModeMaxIterations => "Chaos mode: max iterations reached",
224            TerminationReason::RestartRequested => "Restarting by human request",
225        }
226    }
227
228    /// Extracts task lines from the scratchpad file.
229    ///
230    /// Looks for lines matching `- [ ]`, `- [x]`, or `- [~]` patterns.
231    fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
232        let content = fs::read_to_string(scratchpad_path).ok()?;
233        let mut tasks = String::new();
234
235        for line in content.lines() {
236            let trimmed = line.trim();
237            if trimmed.starts_with("- [ ]")
238                || trimmed.starts_with("- [x]")
239                || trimmed.starts_with("- [~]")
240            {
241                tasks.push_str(trimmed);
242                tasks.push('\n');
243            }
244        }
245
246        if tasks.is_empty() { None } else { Some(tasks) }
247    }
248
249    /// Summarizes events from the event history file.
250    fn summarize_events(&self) -> String {
251        let history = match &self.events_path {
252            Some(path) => EventHistory::new(path),
253            None => EventHistory::default_path(),
254        };
255
256        let records = match history.read_all() {
257            Ok(r) => r,
258            Err(_) => return "_No event history found._\n".to_string(),
259        };
260
261        if records.is_empty() {
262            return "_No events recorded._\n".to_string();
263        }
264
265        // Count events by topic
266        let mut topic_counts: HashMap<String, usize> = HashMap::new();
267        for record in &records {
268            *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
269        }
270
271        let mut summary = format!("- {} total events\n", records.len());
272
273        // Sort by count descending for consistent output
274        let mut sorted: Vec<_> = topic_counts.into_iter().collect();
275        sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
276
277        for (topic, count) in sorted {
278            summary.push_str(&format!("- {} {}\n", count, topic));
279        }
280
281        summary
282    }
283}
284
285/// Formats a duration as human-readable string (e.g., "23m 45s" or "1h 5m 30s").
286fn format_duration(d: Duration) -> String {
287    let total_secs = d.as_secs();
288    let hours = total_secs / 3600;
289    let minutes = (total_secs % 3600) / 60;
290    let seconds = total_secs % 60;
291
292    if hours > 0 {
293        format!("{}h {}m {}s", hours, minutes, seconds)
294    } else if minutes > 0 {
295        format!("{}m {}s", minutes, seconds)
296    } else {
297        format!("{}s", seconds)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::time::Instant;
305    use tempfile::TempDir;
306
307    fn test_state() -> LoopState {
308        LoopState {
309            iteration: 12,
310            consecutive_failures: 0,
311            cumulative_cost: 1.50,
312            started_at: Instant::now(),
313            last_hat: None,
314            consecutive_blocked: 0,
315            last_blocked_hat: None,
316            task_block_counts: std::collections::HashMap::new(),
317            abandoned_tasks: Vec::new(),
318            abandoned_task_redispatches: 0,
319            consecutive_malformed_events: 0,
320            hat_activation_counts: std::collections::HashMap::new(),
321            exhausted_hats: std::collections::HashSet::new(),
322            last_checkin_at: None,
323        }
324    }
325
326    #[test]
327    fn test_status_text() {
328        let writer = SummaryWriter::default();
329
330        assert_eq!(
331            writer.status_text(&TerminationReason::CompletionPromise),
332            "Completed successfully"
333        );
334        assert_eq!(
335            writer.status_text(&TerminationReason::MaxIterations),
336            "Stopped: max iterations reached"
337        );
338        assert_eq!(
339            writer.status_text(&TerminationReason::ConsecutiveFailures),
340            "Failed: too many consecutive failures"
341        );
342        assert_eq!(
343            writer.status_text(&TerminationReason::Interrupted),
344            "Interrupted by signal"
345        );
346    }
347
348    #[test]
349    fn test_format_duration() {
350        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
351        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
352        assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
353    }
354
355    #[test]
356    fn test_extract_tasks() {
357        let tmp = TempDir::new().unwrap();
358        let scratchpad = tmp.path().join("scratchpad.md");
359
360        let content = r"# Tasks
361
362Some intro text.
363
364- [x] Implement feature A
365- [ ] Implement feature B
366- [~] Feature C (cancelled: not needed)
367
368## Notes
369
370More text here.
371";
372        fs::write(&scratchpad, content).unwrap();
373
374        let writer = SummaryWriter::default();
375        let tasks = writer.extract_tasks(&scratchpad).unwrap();
376
377        assert!(tasks.contains("- [x] Implement feature A"));
378        assert!(tasks.contains("- [ ] Implement feature B"));
379        assert!(tasks.contains("- [~] Feature C"));
380    }
381
382    #[test]
383    fn test_generate_content_basic() {
384        let writer = SummaryWriter::default();
385        let state = test_state();
386
387        let content = writer.generate_content_with_landing(
388            &TerminationReason::CompletionPromise,
389            &state,
390            None,
391            Some("abc1234: feat(auth): add tokens"),
392            None,
393        );
394
395        assert!(content.contains("# Loop Summary"));
396        assert!(content.contains("**Status:** Completed successfully"));
397        assert!(content.contains("**Iterations:** 12"));
398        assert!(content.contains("**Est. cost:** $1.50"));
399        assert!(content.contains("## Tasks"));
400        assert!(content.contains("## Events"));
401        assert!(content.contains("## Final Commit"));
402        assert!(content.contains("abc1234: feat(auth): add tokens"));
403    }
404
405    #[test]
406    fn test_write_creates_directory() {
407        let tmp = TempDir::new().unwrap();
408        let path = tmp.path().join("nested/dir/summary.md");
409
410        let writer = SummaryWriter::new(&path);
411        let state = test_state();
412
413        writer
414            .write(&TerminationReason::CompletionPromise, &state, None, None)
415            .unwrap();
416
417        assert!(path.exists());
418        let content = fs::read_to_string(path).unwrap();
419        assert!(content.contains("# Loop Summary"));
420    }
421
422    #[test]
423    fn test_write_with_landing() {
424        let tmp = TempDir::new().unwrap();
425        let path = tmp.path().join("summary.md");
426
427        let writer = SummaryWriter::new(&path);
428        let state = test_state();
429
430        let landing = LandingResult {
431            committed: true,
432            commit_sha: Some("abc1234".to_string()),
433            handoff_path: tmp.path().join("handoff.md"),
434            open_tasks: vec!["task-1".to_string(), "task-2".to_string()],
435            stashes_cleared: 2,
436            working_tree_clean: true,
437        };
438
439        writer
440            .write_with_landing(
441                &TerminationReason::CompletionPromise,
442                &state,
443                None,
444                None,
445                Some(&landing),
446            )
447            .unwrap();
448
449        let content = fs::read_to_string(path).unwrap();
450        assert!(content.contains("## Landing"));
451        assert!(content.contains("**Auto-committed:** Yes (abc1234)"));
452        assert!(content.contains("**Handoff:**"));
453        assert!(content.contains("**Open tasks:** 2"));
454        assert!(content.contains("**Stashes cleared:** 2"));
455        assert!(content.contains("**Working tree clean:** Yes"));
456    }
457}