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        }
225    }
226
227    /// Extracts task lines from the scratchpad file.
228    ///
229    /// Looks for lines matching `- [ ]`, `- [x]`, or `- [~]` patterns.
230    fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
231        let content = fs::read_to_string(scratchpad_path).ok()?;
232        let mut tasks = String::new();
233
234        for line in content.lines() {
235            let trimmed = line.trim();
236            if trimmed.starts_with("- [ ]")
237                || trimmed.starts_with("- [x]")
238                || trimmed.starts_with("- [~]")
239            {
240                tasks.push_str(trimmed);
241                tasks.push('\n');
242            }
243        }
244
245        if tasks.is_empty() { None } else { Some(tasks) }
246    }
247
248    /// Summarizes events from the event history file.
249    fn summarize_events(&self) -> String {
250        let history = match &self.events_path {
251            Some(path) => EventHistory::new(path),
252            None => EventHistory::default_path(),
253        };
254
255        let records = match history.read_all() {
256            Ok(r) => r,
257            Err(_) => return "_No event history found._\n".to_string(),
258        };
259
260        if records.is_empty() {
261            return "_No events recorded._\n".to_string();
262        }
263
264        // Count events by topic
265        let mut topic_counts: HashMap<String, usize> = HashMap::new();
266        for record in &records {
267            *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
268        }
269
270        let mut summary = format!("- {} total events\n", records.len());
271
272        // Sort by count descending for consistent output
273        let mut sorted: Vec<_> = topic_counts.into_iter().collect();
274        sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
275
276        for (topic, count) in sorted {
277            summary.push_str(&format!("- {} {}\n", count, topic));
278        }
279
280        summary
281    }
282}
283
284/// Formats a duration as human-readable string (e.g., "23m 45s" or "1h 5m 30s").
285fn format_duration(d: Duration) -> String {
286    let total_secs = d.as_secs();
287    let hours = total_secs / 3600;
288    let minutes = (total_secs % 3600) / 60;
289    let seconds = total_secs % 60;
290
291    if hours > 0 {
292        format!("{}h {}m {}s", hours, minutes, seconds)
293    } else if minutes > 0 {
294        format!("{}m {}s", minutes, seconds)
295    } else {
296        format!("{}s", seconds)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::time::Instant;
304    use tempfile::TempDir;
305
306    fn test_state() -> LoopState {
307        LoopState {
308            iteration: 12,
309            consecutive_failures: 0,
310            cumulative_cost: 1.50,
311            started_at: Instant::now(),
312            last_hat: None,
313            consecutive_blocked: 0,
314            last_blocked_hat: None,
315            task_block_counts: std::collections::HashMap::new(),
316            abandoned_tasks: Vec::new(),
317            abandoned_task_redispatches: 0,
318            consecutive_malformed_events: 0,
319            hat_activation_counts: std::collections::HashMap::new(),
320            exhausted_hats: std::collections::HashSet::new(),
321        }
322    }
323
324    #[test]
325    fn test_status_text() {
326        let writer = SummaryWriter::default();
327
328        assert_eq!(
329            writer.status_text(&TerminationReason::CompletionPromise),
330            "Completed successfully"
331        );
332        assert_eq!(
333            writer.status_text(&TerminationReason::MaxIterations),
334            "Stopped: max iterations reached"
335        );
336        assert_eq!(
337            writer.status_text(&TerminationReason::ConsecutiveFailures),
338            "Failed: too many consecutive failures"
339        );
340        assert_eq!(
341            writer.status_text(&TerminationReason::Interrupted),
342            "Interrupted by signal"
343        );
344    }
345
346    #[test]
347    fn test_format_duration() {
348        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
349        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
350        assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
351    }
352
353    #[test]
354    fn test_extract_tasks() {
355        let tmp = TempDir::new().unwrap();
356        let scratchpad = tmp.path().join("scratchpad.md");
357
358        let content = r"# Tasks
359
360Some intro text.
361
362- [x] Implement feature A
363- [ ] Implement feature B
364- [~] Feature C (cancelled: not needed)
365
366## Notes
367
368More text here.
369";
370        fs::write(&scratchpad, content).unwrap();
371
372        let writer = SummaryWriter::default();
373        let tasks = writer.extract_tasks(&scratchpad).unwrap();
374
375        assert!(tasks.contains("- [x] Implement feature A"));
376        assert!(tasks.contains("- [ ] Implement feature B"));
377        assert!(tasks.contains("- [~] Feature C"));
378    }
379
380    #[test]
381    fn test_generate_content_basic() {
382        let writer = SummaryWriter::default();
383        let state = test_state();
384
385        let content = writer.generate_content_with_landing(
386            &TerminationReason::CompletionPromise,
387            &state,
388            None,
389            Some("abc1234: feat(auth): add tokens"),
390            None,
391        );
392
393        assert!(content.contains("# Loop Summary"));
394        assert!(content.contains("**Status:** Completed successfully"));
395        assert!(content.contains("**Iterations:** 12"));
396        assert!(content.contains("**Est. cost:** $1.50"));
397        assert!(content.contains("## Tasks"));
398        assert!(content.contains("## Events"));
399        assert!(content.contains("## Final Commit"));
400        assert!(content.contains("abc1234: feat(auth): add tokens"));
401    }
402
403    #[test]
404    fn test_write_creates_directory() {
405        let tmp = TempDir::new().unwrap();
406        let path = tmp.path().join("nested/dir/summary.md");
407
408        let writer = SummaryWriter::new(&path);
409        let state = test_state();
410
411        writer
412            .write(&TerminationReason::CompletionPromise, &state, None, None)
413            .unwrap();
414
415        assert!(path.exists());
416        let content = fs::read_to_string(path).unwrap();
417        assert!(content.contains("# Loop Summary"));
418    }
419
420    #[test]
421    fn test_write_with_landing() {
422        let tmp = TempDir::new().unwrap();
423        let path = tmp.path().join("summary.md");
424
425        let writer = SummaryWriter::new(&path);
426        let state = test_state();
427
428        let landing = LandingResult {
429            committed: true,
430            commit_sha: Some("abc1234".to_string()),
431            handoff_path: tmp.path().join("handoff.md"),
432            open_tasks: vec!["task-1".to_string(), "task-2".to_string()],
433            stashes_cleared: 2,
434            working_tree_clean: true,
435        };
436
437        writer
438            .write_with_landing(
439                &TerminationReason::CompletionPromise,
440                &state,
441                None,
442                None,
443                Some(&landing),
444            )
445            .unwrap();
446
447        let content = fs::read_to_string(path).unwrap();
448        assert!(content.contains("## Landing"));
449        assert!(content.contains("**Auto-committed:** Yes (abc1234)"));
450        assert!(content.contains("**Handoff:**"));
451        assert!(content.contains("**Open tasks:** 2"));
452        assert!(content.contains("**Stashes cleared:** 2"));
453        assert!(content.contains("**Working tree clean:** Yes"));
454    }
455}