use crate::{agent_result, events};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone)]
pub struct AttemptEntry {
pub timestamp: u64,
pub event: Option<serde_json::Value>,
pub capture_files: Vec<PathBuf>,
pub review_files: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct AttemptTimeline {
pub phase: u32,
pub entries: Vec<AttemptEntry>,
}
pub fn attempt_timeline(project_root: &Path, phase: u32) -> AttemptTimeline {
let mut indexed_events = std::fs::read_to_string(events::events_path(project_root))
.unwrap_or_default()
.lines()
.enumerate()
.filter_map(|(index, line)| {
let event = serde_json::from_str::<serde_json::Value>(line).ok()?;
(event.get("v").and_then(|v| v.as_u64()) == Some(1)
&& event.get("phase").and_then(|p| p.as_u64()) == Some(phase as u64))
.then(|| {
let timestamp = event.get("ts").and_then(|ts| ts.as_u64()).unwrap_or(0);
(timestamp, index, event)
})
})
.collect::<Vec<_>>();
indexed_events.sort_by_key(|(timestamp, index, _)| (*timestamp, *index));
let mut entries = indexed_events
.into_iter()
.map(|(timestamp, _, event)| AttemptEntry {
timestamp,
event: Some(event),
capture_files: Vec::new(),
review_files: Vec::new(),
})
.collect::<Vec<_>>();
for generation in capture_generations(project_root, phase) {
if let Some(index) = entries.iter().position(|entry| {
entry
.event
.as_ref()
.and_then(|event| event.get("stamp"))
.and_then(|stamp| stamp.as_str())
== Some(generation.stamp.as_str())
}) {
entries[index]
.capture_files
.extend(generation.capture_files);
entries[index].review_files.extend(generation.review_files);
} else {
attach_artifacts(
&mut entries,
generation.timestamp,
generation.capture_files,
true,
);
attach_artifacts(
&mut entries,
generation.timestamp,
generation.review_files,
false,
);
}
}
for review in review_files(project_root, phase) {
let timestamp = modified_timestamp(&review);
attach_artifacts(&mut entries, timestamp, vec![review], false);
}
entries.sort_by_key(|entry| entry.timestamp);
AttemptTimeline { phase, entries }
}
pub fn render_timeline(timeline: &AttemptTimeline) -> String {
if timeline.entries.is_empty() {
return format!("no attempts recorded for phase {}", timeline.phase);
}
let mut rendered = format!("attempt history for phase {}\n", timeline.phase);
for entry in &timeline.entries {
let summary = entry
.event
.as_ref()
.map(events::describe)
.unwrap_or_else(|| "retained artifact".into());
rendered.push_str(&format!("[{}] {summary}\n", entry.timestamp));
for capture in &entry.capture_files {
rendered.push_str(&format!(" capture: {}\n", capture.display()));
}
for review in &entry.review_files {
rendered.push_str(&format!(" review: {}\n", review.display()));
}
}
rendered.trim_end().to_string()
}
struct CaptureGeneration {
stamp: String,
timestamp: u64,
sequence: u64,
capture_files: Vec<PathBuf>,
review_files: Vec<PathBuf>,
}
fn capture_generations(project_root: &Path, phase: u32) -> Vec<CaptureGeneration> {
let dir = agent_result::history_dir(project_root, phase);
let Ok(files) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut generations: BTreeMap<String, CaptureGeneration> = BTreeMap::new();
for file in files.flatten() {
let path = file.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let stamp = name
.strip_suffix("-stdout")
.or_else(|| name.strip_suffix("-exit"))
.or_else(|| name.strip_suffix("-REVIEW.md"));
let Some(stamp) = stamp else { continue };
let Some(nanos) = stamp
.split('-')
.next()
.and_then(|value| value.parse::<u128>().ok())
else {
continue;
};
let timestamp = (nanos / 1_000_000_000).min(u64::MAX as u128) as u64;
let sequence = stamp
.split('-')
.nth(1)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
let generation =
generations
.entry(stamp.to_string())
.or_insert_with(|| CaptureGeneration {
stamp: stamp.to_string(),
timestamp,
sequence,
capture_files: Vec::new(),
review_files: Vec::new(),
});
if name.ends_with("-REVIEW.md") {
generation.review_files.push(path);
} else {
generation.capture_files.push(path);
}
}
let mut generations = generations.into_values().collect::<Vec<_>>();
for generation in &mut generations {
generation.capture_files.sort();
generation.review_files.sort();
}
generations.sort_by_key(|generation| (generation.timestamp, generation.sequence));
generations
}
fn review_files(project_root: &Path, phase: u32) -> Vec<PathBuf> {
let phases = project_root.join(".planning").join("phases");
let prefix = format!("{phase:02}-");
let mut reviews = Vec::new();
let Ok(dirs) = std::fs::read_dir(phases) else {
return reviews;
};
for dir in dirs.flatten() {
if !dir
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(&prefix))
{
continue;
}
collect_reviews(&dir.path(), &mut reviews);
}
reviews.sort_by_key(|path| (modified_timestamp(path), path.clone()));
reviews
}
fn collect_reviews(dir: &Path, reviews: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
collect_reviews(&path, reviews);
} else if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("REVIEW.md"))
{
reviews.push(path);
}
}
}
fn modified_timestamp(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn attach_artifacts(
entries: &mut Vec<AttemptEntry>,
timestamp: u64,
files: Vec<PathBuf>,
captures: bool,
) {
if entries.is_empty() {
entries.push(AttemptEntry {
timestamp,
event: None,
capture_files: Vec::new(),
review_files: Vec::new(),
});
}
let index = entries
.iter()
.rposition(|entry| entry.timestamp <= timestamp)
.unwrap_or(0);
if captures {
entries[index].capture_files.extend(files);
} else {
entries[index].review_files.extend(files);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn seed_event_log(root: &Path) {
let path = events::events_path(root);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
path,
concat!(
r#"{"v":1,"ts":30,"phase":16,"event":"hook_run","hook":"Merge"}"#,
"\n",
r#"{"v":1,"ts":10,"phase":16,"event":"transition","to":"code"}"#,
"\n",
r#"{"v":1,"ts":20,"phase":16,"event":"gate_fired","stage":"ship"}"#,
"\n",
r#"{"v":1,"ts":21,"phase":16,"event":"capture_archived","stage":"ship","stamp":"20000000000-0"}"#,
"\n",
r#"{"v":1,"ts":15,"phase":99,"event":"workflow_started"}"#,
"\n",
),
)
.unwrap();
}
#[test]
fn timeline_orders_events_and_correlates_retained_captures() {
let dir = tempfile::tempdir().unwrap();
seed_event_log(dir.path());
let captures = agent_result::history_dir(dir.path(), 16);
std::fs::create_dir_all(&captures).unwrap();
std::fs::write(captures.join("20000000000-0-stdout"), "attempt output").unwrap();
std::fs::write(captures.join("20000000000-0-exit"), "1").unwrap();
let timeline = attempt_timeline(dir.path(), 16);
assert_eq!(timeline.entries.len(), 4);
assert_eq!(timeline.entries[0].timestamp, 10);
assert_eq!(timeline.entries[1].timestamp, 20);
assert_eq!(timeline.entries[2].timestamp, 21);
assert_eq!(timeline.entries[3].timestamp, 30);
assert_eq!(timeline.entries[2].capture_files.len(), 2);
assert!(
timeline.entries[2]
.capture_files
.iter()
.all(|path| path.starts_with(&captures))
);
let rendered = render_timeline(&timeline);
assert!(rendered.contains("transition (code)"));
assert!(rendered.contains("gate_fired (ship)"));
assert!(rendered.contains("capture:"));
}
#[test]
fn empty_phase_has_clean_no_attempts_result() {
let dir = tempfile::tempdir().unwrap();
let timeline = attempt_timeline(dir.path(), 42);
assert!(timeline.entries.is_empty());
assert_eq!(
render_timeline(&timeline),
"no attempts recorded for phase 42"
);
}
}