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::ChaosModeComplete => "Chaos mode: exploration complete",
223 TerminationReason::ChaosModeMaxIterations => "Chaos mode: max iterations reached",
224 TerminationReason::RestartRequested => "Restarting by human request",
225 }
226 }
227
228 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 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 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 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
285fn 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}