1use 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#[derive(Debug)]
41pub struct SummaryWriter {
42 path: PathBuf,
43 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 pub fn new(path: impl Into<PathBuf>) -> Self {
57 Self {
58 path: path.into(),
59 events_path: None,
60 }
61 }
62
63 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 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 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 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 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 content.push_str("# Loop Summary\n\n");
127
128 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 if state.cumulative_cost > 0.0 {
139 content.push_str(&format!("**Est. cost:** ${:.2}\n", state.cumulative_cost));
140 }
141
142 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 content.push('\n');
153 content.push_str("## Events\n\n");
154 content.push_str(&self.summarize_events());
155
156 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 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 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 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 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 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 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
283fn 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}