Skip to main content

devflow_core/
history.rs

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