1use crate::{agent_result, events};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8#[derive(Debug, Clone)]
11pub struct AttemptEntry {
12 pub timestamp: u64,
13 pub event: Option<serde_json::Value>,
14 pub capture_files: Vec<PathBuf>,
15 pub review_files: Vec<PathBuf>,
16}
17
18#[derive(Debug, Clone)]
20pub struct AttemptTimeline {
21 pub phase: u32,
22 pub entries: Vec<AttemptEntry>,
23}
24
25pub fn attempt_timeline(project_root: &Path, phase: u32) -> AttemptTimeline {
28 let mut indexed_events = std::fs::read_to_string(events::events_path(project_root))
29 .unwrap_or_default()
30 .lines()
31 .enumerate()
32 .filter_map(|(index, line)| {
33 let event = serde_json::from_str::<serde_json::Value>(line).ok()?;
34 (event.get("v").and_then(|v| v.as_u64()) == Some(1)
35 && event.get("phase").and_then(|p| p.as_u64()) == Some(phase as u64))
36 .then(|| {
37 let timestamp = event.get("ts").and_then(|ts| ts.as_u64()).unwrap_or(0);
38 (timestamp, index, event)
39 })
40 })
41 .collect::<Vec<_>>();
42 indexed_events.sort_by_key(|(timestamp, index, _)| (*timestamp, *index));
43
44 let mut entries = indexed_events
45 .into_iter()
46 .map(|(timestamp, _, event)| AttemptEntry {
47 timestamp,
48 event: Some(event),
49 capture_files: Vec::new(),
50 review_files: Vec::new(),
51 })
52 .collect::<Vec<_>>();
53
54 for generation in capture_generations(project_root, phase) {
55 if let Some(index) = entries.iter().position(|entry| {
56 entry
57 .event
58 .as_ref()
59 .and_then(|event| event.get("stamp"))
60 .and_then(|stamp| stamp.as_str())
61 == Some(generation.stamp.as_str())
62 }) {
63 entries[index]
64 .capture_files
65 .extend(generation.capture_files);
66 entries[index].review_files.extend(generation.review_files);
67 } else {
68 attach_artifacts(
69 &mut entries,
70 generation.timestamp,
71 generation.capture_files,
72 true,
73 );
74 attach_artifacts(
75 &mut entries,
76 generation.timestamp,
77 generation.review_files,
78 false,
79 );
80 }
81 }
82 for review in review_files(project_root, phase) {
83 let timestamp = modified_timestamp(&review);
84 attach_artifacts(&mut entries, timestamp, vec![review], false);
85 }
86 entries.sort_by_key(|entry| entry.timestamp);
87
88 AttemptTimeline { phase, entries }
89}
90
91pub fn render_timeline(timeline: &AttemptTimeline) -> String {
94 if timeline.entries.is_empty() {
95 return format!("no attempts recorded for phase {}", timeline.phase);
96 }
97
98 let mut rendered = format!("attempt history for phase {}\n", timeline.phase);
99 for entry in &timeline.entries {
100 let summary = entry
101 .event
102 .as_ref()
103 .map(events::describe)
104 .unwrap_or_else(|| "retained artifact".into());
105 rendered.push_str(&format!("[{}] {summary}\n", entry.timestamp));
106 for capture in &entry.capture_files {
107 rendered.push_str(&format!(" capture: {}\n", capture.display()));
108 }
109 for review in &entry.review_files {
110 rendered.push_str(&format!(" review: {}\n", review.display()));
111 }
112 }
113 rendered.trim_end().to_string()
114}
115
116struct CaptureGeneration {
117 stamp: String,
118 timestamp: u64,
119 sequence: u64,
120 capture_files: Vec<PathBuf>,
121 review_files: Vec<PathBuf>,
122}
123
124fn capture_generations(project_root: &Path, phase: u32) -> Vec<CaptureGeneration> {
125 let dir = agent_result::history_dir(project_root, phase);
126 let Ok(files) = std::fs::read_dir(dir) else {
127 return Vec::new();
128 };
129 let mut generations: BTreeMap<String, CaptureGeneration> = BTreeMap::new();
130 for file in files.flatten() {
131 let path = file.path();
132 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
133 continue;
134 };
135 let stamp = name
136 .strip_suffix("-stdout")
137 .or_else(|| name.strip_suffix("-exit"))
138 .or_else(|| name.strip_suffix("-REVIEW.md"));
139 let Some(stamp) = stamp else { continue };
140 let Some(nanos) = stamp
141 .split('-')
142 .next()
143 .and_then(|value| value.parse::<u128>().ok())
144 else {
145 continue;
146 };
147 let timestamp = (nanos / 1_000_000_000).min(u64::MAX as u128) as u64;
148 let sequence = stamp
149 .split('-')
150 .nth(1)
151 .and_then(|value| value.parse::<u64>().ok())
152 .unwrap_or(0);
153 let generation =
154 generations
155 .entry(stamp.to_string())
156 .or_insert_with(|| CaptureGeneration {
157 stamp: stamp.to_string(),
158 timestamp,
159 sequence,
160 capture_files: Vec::new(),
161 review_files: Vec::new(),
162 });
163 if name.ends_with("-REVIEW.md") {
164 generation.review_files.push(path);
165 } else {
166 generation.capture_files.push(path);
167 }
168 }
169 let mut generations = generations.into_values().collect::<Vec<_>>();
170 for generation in &mut generations {
171 generation.capture_files.sort();
172 generation.review_files.sort();
173 }
174 generations.sort_by_key(|generation| (generation.timestamp, generation.sequence));
175 generations
176}
177
178fn review_files(project_root: &Path, phase: u32) -> Vec<PathBuf> {
179 let phases = project_root.join(".planning").join("phases");
180 let prefix = format!("{phase:02}-");
181 let mut reviews = Vec::new();
182 let Ok(dirs) = std::fs::read_dir(phases) else {
183 return reviews;
184 };
185 for dir in dirs.flatten() {
186 if !dir
187 .file_name()
188 .to_str()
189 .is_some_and(|name| name.starts_with(&prefix))
190 {
191 continue;
192 }
193 collect_reviews(&dir.path(), &mut reviews);
194 }
195 reviews.sort_by_key(|path| (modified_timestamp(path), path.clone()));
196 reviews
197}
198
199fn collect_reviews(dir: &Path, reviews: &mut Vec<PathBuf>) {
200 let Ok(entries) = std::fs::read_dir(dir) else {
201 return;
202 };
203 for entry in entries.flatten() {
204 let path = entry.path();
205 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
206 collect_reviews(&path, reviews);
207 } else if path
208 .file_name()
209 .and_then(|name| name.to_str())
210 .is_some_and(|name| name.ends_with("REVIEW.md"))
211 {
212 reviews.push(path);
213 }
214 }
215}
216
217fn modified_timestamp(path: &Path) -> u64 {
218 std::fs::metadata(path)
219 .and_then(|metadata| metadata.modified())
220 .ok()
221 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
222 .map(|duration| duration.as_secs())
223 .unwrap_or(0)
224}
225
226fn attach_artifacts(
227 entries: &mut Vec<AttemptEntry>,
228 timestamp: u64,
229 files: Vec<PathBuf>,
230 captures: bool,
231) {
232 if entries.is_empty() {
233 entries.push(AttemptEntry {
234 timestamp,
235 event: None,
236 capture_files: Vec::new(),
237 review_files: Vec::new(),
238 });
239 }
240 let index = entries
241 .iter()
242 .rposition(|entry| entry.timestamp <= timestamp)
243 .unwrap_or(0);
244 if captures {
245 entries[index].capture_files.extend(files);
246 } else {
247 entries[index].review_files.extend(files);
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::path::Path;
255
256 fn seed_event_log(root: &Path) {
257 let path = events::events_path(root);
258 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
259 std::fs::write(
260 path,
261 concat!(
262 r#"{"v":1,"ts":30,"phase":16,"event":"hook_run","hook":"Merge"}"#,
263 "\n",
264 r#"{"v":1,"ts":10,"phase":16,"event":"transition","to":"code"}"#,
265 "\n",
266 r#"{"v":1,"ts":20,"phase":16,"event":"gate_fired","stage":"ship"}"#,
267 "\n",
268 r#"{"v":1,"ts":21,"phase":16,"event":"capture_archived","stage":"ship","stamp":"20000000000-0"}"#,
269 "\n",
270 r#"{"v":1,"ts":15,"phase":99,"event":"workflow_started"}"#,
271 "\n",
272 ),
273 )
274 .unwrap();
275 }
276
277 #[test]
278 fn timeline_orders_events_and_correlates_retained_captures() {
279 let dir = tempfile::tempdir().unwrap();
280 seed_event_log(dir.path());
281 let captures = agent_result::history_dir(dir.path(), 16);
282 std::fs::create_dir_all(&captures).unwrap();
283 std::fs::write(captures.join("20000000000-0-stdout"), "attempt output").unwrap();
284 std::fs::write(captures.join("20000000000-0-exit"), "1").unwrap();
285
286 let timeline = attempt_timeline(dir.path(), 16);
287
288 assert_eq!(timeline.entries.len(), 4);
289 assert_eq!(timeline.entries[0].timestamp, 10);
290 assert_eq!(timeline.entries[1].timestamp, 20);
291 assert_eq!(timeline.entries[2].timestamp, 21);
292 assert_eq!(timeline.entries[3].timestamp, 30);
293 assert_eq!(timeline.entries[2].capture_files.len(), 2);
294 assert!(
295 timeline.entries[2]
296 .capture_files
297 .iter()
298 .all(|path| path.starts_with(&captures))
299 );
300 let rendered = render_timeline(&timeline);
301 assert!(rendered.contains("transition (code)"));
302 assert!(rendered.contains("gate_fired (ship)"));
303 assert!(rendered.contains("capture:"));
304 }
305
306 #[test]
307 fn empty_phase_has_clean_no_attempts_result() {
308 let dir = tempfile::tempdir().unwrap();
309 let timeline = attempt_timeline(dir.path(), 42);
310
311 assert!(timeline.entries.is_empty());
312 assert_eq!(
313 render_timeline(&timeline),
314 "no attempts recorded for phase 42"
315 );
316 }
317}