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