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::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 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 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 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 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
286fn 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}