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