use crate::event_logger::EventHistory;
use crate::event_loop::{LoopState, TerminationReason};
use crate::landing::LandingResult;
use crate::loop_context::LoopContext;
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug)]
pub struct SummaryWriter {
path: PathBuf,
events_path: Option<PathBuf>,
}
impl Default for SummaryWriter {
fn default() -> Self {
Self::new(".ralph/agent/summary.md")
}
}
impl SummaryWriter {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
events_path: None,
}
}
pub fn from_context(context: &LoopContext) -> Self {
Self {
path: context.summary_path(),
events_path: Some(context.events_path()),
}
}
pub fn write(
&self,
reason: &TerminationReason,
state: &LoopState,
scratchpad_path: Option<&Path>,
final_commit: Option<&str>,
) -> io::Result<()> {
self.write_with_landing(reason, state, scratchpad_path, final_commit, None)
}
pub fn write_with_landing(
&self,
reason: &TerminationReason,
state: &LoopState,
scratchpad_path: Option<&Path>,
final_commit: Option<&str>,
landing: Option<&LandingResult>,
) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let content = self.generate_content_with_landing(
reason,
state,
scratchpad_path,
final_commit,
landing,
);
fs::write(&self.path, content)
}
fn generate_content_with_landing(
&self,
reason: &TerminationReason,
state: &LoopState,
scratchpad_path: Option<&Path>,
final_commit: Option<&str>,
landing: Option<&LandingResult>,
) -> String {
let mut content = String::new();
content.push_str("# Loop Summary\n\n");
let status = self.status_text(reason);
content.push_str(&format!("**Status:** {status}\n"));
content.push_str(&format!("**Iterations:** {}\n", state.iteration));
content.push_str(&format!(
"**Duration:** {}\n",
format_duration(state.elapsed())
));
if state.cumulative_cost > 0.0 {
content.push_str(&format!("**Est. cost:** ${:.2}\n", state.cumulative_cost));
}
content.push('\n');
content.push_str("## Tasks\n\n");
if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
content.push_str(&tasks);
} else {
content.push_str("_No scratchpad found._\n");
}
content.push('\n');
content.push_str("## Events\n\n");
content.push_str(&self.summarize_events());
if let Some(commit) = final_commit {
content.push('\n');
content.push_str("## Final Commit\n\n");
content.push_str(commit);
content.push('\n');
}
if let Some(landing_result) = landing {
content.push('\n');
content.push_str("## Landing\n\n");
if landing_result.committed {
content.push_str(&format!(
"- **Auto-committed:** Yes ({})\n",
landing_result.commit_sha.as_deref().unwrap_or("unknown")
));
} else {
content.push_str("- **Auto-committed:** No (working tree was clean)\n");
}
content.push_str(&format!(
"- **Handoff:** `{}`\n",
landing_result.handoff_path.display()
));
if !landing_result.open_tasks.is_empty() {
content.push_str(&format!(
"- **Open tasks:** {}\n",
landing_result.open_tasks.len()
));
}
if landing_result.stashes_cleared > 0 {
content.push_str(&format!(
"- **Stashes cleared:** {}\n",
landing_result.stashes_cleared
));
}
content.push_str(&format!(
"- **Working tree clean:** {}\n",
if landing_result.working_tree_clean {
"Yes"
} else {
"No"
}
));
}
content
}
fn status_text(&self, reason: &TerminationReason) -> &'static str {
match reason {
TerminationReason::CompletionPromise => "Completed successfully",
TerminationReason::MaxIterations => "Stopped: max iterations reached",
TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
TerminationReason::MaxCost => "Stopped: max cost exceeded",
TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
TerminationReason::LoopStale => "Failed: stale loop detected",
TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
TerminationReason::Stopped => "Stopped manually",
TerminationReason::Interrupted => "Interrupted by signal",
TerminationReason::RestartRequested => "Restarting by human request",
TerminationReason::WorkspaceGone => "Failed: workspace directory removed",
TerminationReason::Cancelled => "Cancelled gracefully (human rejection or timeout)",
}
}
fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
let content = fs::read_to_string(scratchpad_path).ok()?;
let mut tasks = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("- [ ]")
|| trimmed.starts_with("- [x]")
|| trimmed.starts_with("- [~]")
{
tasks.push_str(trimmed);
tasks.push('\n');
}
}
if tasks.is_empty() { None } else { Some(tasks) }
}
fn summarize_events(&self) -> String {
let history = match &self.events_path {
Some(path) => EventHistory::new(path),
None => EventHistory::default_path(),
};
let records = match history.read_all() {
Ok(r) => r,
Err(_) => return "_No event history found._\n".to_string(),
};
if records.is_empty() {
return "_No events recorded._\n".to_string();
}
let mut topic_counts: HashMap<String, usize> = HashMap::new();
for record in &records {
*topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
}
let mut summary = format!("- {} total events\n", records.len());
let mut sorted: Vec<_> = topic_counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (topic, count) in sorted {
summary.push_str(&format!("- {} {}\n", count, topic));
}
summary
}
}
fn format_duration(d: Duration) -> String {
let total_secs = d.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
use tempfile::TempDir;
fn test_state() -> LoopState {
LoopState {
iteration: 12,
consecutive_failures: 0,
cumulative_cost: 1.50,
started_at: Instant::now(),
last_hat: None,
consecutive_blocked: 0,
last_blocked_hat: None,
task_block_counts: std::collections::HashMap::new(),
abandoned_tasks: Vec::new(),
abandoned_task_redispatches: 0,
consecutive_malformed_events: 0,
completion_requested: false,
hat_activation_counts: std::collections::HashMap::new(),
exhausted_hats: std::collections::HashSet::new(),
last_checkin_at: None,
last_active_hat_ids: Vec::new(),
seen_topics: std::collections::HashSet::new(),
last_emitted_signature: None,
consecutive_same_signature: 0,
cancellation_requested: false,
}
}
#[test]
fn test_status_text() {
let writer = SummaryWriter::default();
assert_eq!(
writer.status_text(&TerminationReason::CompletionPromise),
"Completed successfully"
);
assert_eq!(
writer.status_text(&TerminationReason::MaxIterations),
"Stopped: max iterations reached"
);
assert_eq!(
writer.status_text(&TerminationReason::ConsecutiveFailures),
"Failed: too many consecutive failures"
);
assert_eq!(
writer.status_text(&TerminationReason::Interrupted),
"Interrupted by signal"
);
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(Duration::from_secs(45)), "45s");
assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
}
#[test]
fn test_extract_tasks() {
let tmp = TempDir::new().unwrap();
let scratchpad = tmp.path().join("scratchpad.md");
let content = r"# Tasks
Some intro text.
- [x] Implement feature A
- [ ] Implement feature B
- [~] Feature C (cancelled: not needed)
## Notes
More text here.
";
fs::write(&scratchpad, content).unwrap();
let writer = SummaryWriter::default();
let tasks = writer.extract_tasks(&scratchpad).unwrap();
assert!(tasks.contains("- [x] Implement feature A"));
assert!(tasks.contains("- [ ] Implement feature B"));
assert!(tasks.contains("- [~] Feature C"));
}
#[test]
fn test_generate_content_basic() {
let writer = SummaryWriter::default();
let state = test_state();
let content = writer.generate_content_with_landing(
&TerminationReason::CompletionPromise,
&state,
None,
Some("abc1234: feat(auth): add tokens"),
None,
);
assert!(content.contains("# Loop Summary"));
assert!(content.contains("**Status:** Completed successfully"));
assert!(content.contains("**Iterations:** 12"));
assert!(content.contains("**Est. cost:** $1.50"));
assert!(content.contains("## Tasks"));
assert!(content.contains("## Events"));
assert!(content.contains("## Final Commit"));
assert!(content.contains("abc1234: feat(auth): add tokens"));
}
#[test]
fn test_write_creates_directory() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nested/dir/summary.md");
let writer = SummaryWriter::new(&path);
let state = test_state();
writer
.write(&TerminationReason::CompletionPromise, &state, None, None)
.unwrap();
assert!(path.exists());
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("# Loop Summary"));
}
#[test]
fn test_write_with_landing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("summary.md");
let writer = SummaryWriter::new(&path);
let state = test_state();
let landing = LandingResult {
committed: true,
commit_sha: Some("abc1234".to_string()),
handoff_path: tmp.path().join("handoff.md"),
open_tasks: vec!["task-1".to_string(), "task-2".to_string()],
stashes_cleared: 2,
working_tree_clean: true,
};
writer
.write_with_landing(
&TerminationReason::CompletionPromise,
&state,
None,
None,
Some(&landing),
)
.unwrap();
let content = fs::read_to_string(path).unwrap();
assert!(content.contains("## Landing"));
assert!(content.contains("**Auto-committed:** Yes (abc1234)"));
assert!(content.contains("**Handoff:**"));
assert!(content.contains("**Open tasks:** 2"));
assert!(content.contains("**Stashes cleared:** 2"));
assert!(content.contains("**Working tree clean:** Yes"));
}
}