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