Skip to main content

devflow_core/
history.rs

1//! Read-only per-phase attempt history assembled from existing DevFlow stores.
2
3use 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/// One chronological point in a phase's history, with nearby retained
10/// evidence attached to the event that produced it.
11#[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/// The complete read-only attempt view for one phase.
20#[derive(Debug, Clone)]
21pub struct AttemptTimeline {
22    pub phase: PhaseId,
23    pub entries: Vec<AttemptEntry>,
24}
25
26/// Correlate schema-v1 events, retained capture generations, and review
27/// artifacts without creating a second history store.
28pub 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
92/// Human-readable history output; event summaries deliberately reuse the
93/// schema-v1 formatter used by `devflow status`.
94pub 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}