Skip to main content

devflow_core/
events.rs

1//! Append-only workflow event log — `.devflow/events.jsonl`.
2//!
3//! One JSON object per line, schema v1:
4//!
5//! ```json
6//! {"v":1,"ts":1752600000,"phase":14,"event":"transition","from":"code","to":"validate"}
7//! ```
8//!
9//! Every line carries `v`, `ts` (unix seconds), `phase`, and `event`; the
10//! remaining fields are kind-specific. The log exists so any frontend (TUI,
11//! Hermes plugin, web) can observe a running loop by tailing one file instead
12//! of integrating with DevFlow internals — it is the read side of the gate
13//! notify hook's push side.
14//!
15//! Emission is **fail-soft**: an unwritable log warns and returns — recording
16//! an event must never abort the workflow it records. Appends are a single
17//! `write_all` of a complete line on an `O_APPEND` handle, so concurrent
18//! phase monitors' lines interleave without tearing.
19
20use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::{SystemTime, UNIX_EPOCH};
23use tracing::warn;
24
25/// Path of a project's event log.
26pub fn events_path(project_root: &Path) -> PathBuf {
27    project_root.join(".devflow").join("events.jsonl")
28}
29
30/// Schema version stamped on every line.
31const SCHEMA_VERSION: u32 = 1;
32
33/// Append one event line. `fields` supplies the kind-specific payload and
34/// must be a JSON object (anything else is recorded under a `"data"` key).
35pub fn emit(project_root: &Path, phase: u32, event: &str, fields: serde_json::Value) {
36    let mut line = serde_json::json!({
37        "v": SCHEMA_VERSION,
38        "ts": unix_now(),
39        "phase": phase,
40        "event": event,
41    });
42    match fields {
43        serde_json::Value::Object(map) => {
44            let base = line.as_object_mut().expect("line is an object");
45            for (key, value) in map {
46                // Envelope keys win — a payload must not be able to forge
47                // another phase's identity or a different event kind.
48                base.entry(key).or_insert(value);
49            }
50        }
51        serde_json::Value::Null => {}
52        other => {
53            line["data"] = other;
54        }
55    }
56    let path = events_path(project_root);
57    if let Some(parent) = path.parent()
58        && let Err(err) = std::fs::create_dir_all(parent)
59    {
60        warn!("could not create events dir: {err}");
61        return;
62    }
63    let result = std::fs::OpenOptions::new()
64        .create(true)
65        .append(true)
66        .open(&path)
67        .and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
68    if let Err(err) = result {
69        warn!("could not append to {}: {err}", path.display());
70    }
71}
72
73/// The most recent event per phase, from ONE read + parse pass over the log
74/// (14-CR-10) — `devflow status` renders N phases without N full-file scans.
75pub fn last_events_by_phase(
76    project_root: &Path,
77) -> std::collections::HashMap<u32, serde_json::Value> {
78    let mut latest = std::collections::HashMap::new();
79    let Ok(contents) = std::fs::read_to_string(events_path(project_root)) else {
80        return latest;
81    };
82    for event in contents
83        .lines()
84        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
85    {
86        if let Some(phase) = event.get("phase").and_then(|p| p.as_u64()) {
87            // Later lines overwrite earlier ones — appends are chronological.
88            latest.insert(phase as u32, event);
89        }
90    }
91    latest
92}
93
94/// Read the last event line recorded for `phase`, if any.
95pub fn last_event_for_phase(project_root: &Path, phase: u32) -> Option<serde_json::Value> {
96    last_events_by_phase(project_root).remove(&phase)
97}
98
99/// Render an event as a short human-readable summary ("gate_fired (ship)").
100pub fn describe(event: &serde_json::Value) -> String {
101    let kind = event
102        .get("event")
103        .and_then(|e| e.as_str())
104        .unwrap_or("unknown");
105    let detail = ["to", "stage", "status", "hook", "reason"]
106        .iter()
107        .find_map(|key| event.get(*key).and_then(|v| v.as_str()));
108    match detail {
109        Some(detail) => format!("{kind} ({detail})"),
110        None => kind.to_string(),
111    }
112}
113
114fn unix_now() -> u64 {
115    SystemTime::now()
116        .duration_since(UNIX_EPOCH)
117        .map(|d| d.as_secs())
118        .unwrap_or(0)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn read_lines(root: &Path) -> Vec<serde_json::Value> {
126        std::fs::read_to_string(events_path(root))
127            .unwrap_or_default()
128            .lines()
129            .map(|l| serde_json::from_str(l).expect("every line parses as JSON"))
130            .collect()
131    }
132
133    #[test]
134    fn emit_appends_parseable_lines_with_envelope_fields() {
135        let dir = tempfile::tempdir().unwrap();
136        emit(
137            dir.path(),
138            14,
139            "transition",
140            serde_json::json!({"from": "code", "to": "validate"}),
141        );
142        emit(
143            dir.path(),
144            15,
145            "gate_fired",
146            serde_json::json!({"stage": "ship"}),
147        );
148
149        let lines = read_lines(dir.path());
150        assert_eq!(lines.len(), 2);
151        for line in &lines {
152            assert_eq!(line["v"], 1);
153            assert!(line["ts"].as_u64().is_some());
154            assert!(line["phase"].as_u64().is_some());
155            assert!(line["event"].as_str().is_some());
156        }
157        assert_eq!(lines[0]["phase"], 14);
158        assert_eq!(lines[0]["from"], "code");
159        assert_eq!(lines[1]["phase"], 15);
160        assert_eq!(lines[1]["stage"], "ship");
161    }
162
163    #[test]
164    fn emit_never_lets_payload_forge_envelope_keys() {
165        let dir = tempfile::tempdir().unwrap();
166        emit(
167            dir.path(),
168            7,
169            "transition",
170            serde_json::json!({"phase": 99, "event": "forged", "note": "kept"}),
171        );
172
173        let lines = read_lines(dir.path());
174        assert_eq!(lines[0]["phase"], 7, "envelope phase must win");
175        assert_eq!(lines[0]["event"], "transition", "envelope event must win");
176        assert_eq!(lines[0]["note"], "kept");
177    }
178
179    #[test]
180    fn last_event_for_phase_filters_by_phase() {
181        let dir = tempfile::tempdir().unwrap();
182        emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
183        emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
184        emit(
185            dir.path(),
186            1,
187            "transition",
188            serde_json::json!({"to": "plan"}),
189        );
190
191        let last = last_event_for_phase(dir.path(), 1).expect("phase 1 events exist");
192        assert_eq!(last["event"], "transition");
193        let other = last_event_for_phase(dir.path(), 2).expect("phase 2 events exist");
194        assert_eq!(other["event"], "workflow_started");
195        assert!(last_event_for_phase(dir.path(), 3).is_none());
196    }
197
198    /// 14-CR-10: one pass over the log yields every phase's latest event.
199    #[test]
200    fn last_events_by_phase_collects_latest_per_phase_in_one_pass() {
201        let dir = tempfile::tempdir().unwrap();
202        emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
203        emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
204        emit(
205            dir.path(),
206            1,
207            "transition",
208            serde_json::json!({"to": "plan"}),
209        );
210        emit(
211            dir.path(),
212            2,
213            "gate_fired",
214            serde_json::json!({"stage": "ship"}),
215        );
216
217        let latest = last_events_by_phase(dir.path());
218        assert_eq!(latest.len(), 2);
219        assert_eq!(latest[&1]["event"], "transition");
220        assert_eq!(latest[&2]["event"], "gate_fired");
221        assert!(last_events_by_phase(&dir.path().join("empty")).is_empty());
222    }
223
224    #[test]
225    fn last_event_skips_corrupt_lines() {
226        let dir = tempfile::tempdir().unwrap();
227        emit(dir.path(), 4, "workflow_started", serde_json::Value::Null);
228        let path = events_path(dir.path());
229        let mut contents = std::fs::read_to_string(&path).unwrap();
230        contents.push_str("{truncated\n");
231        std::fs::write(&path, contents).unwrap();
232
233        let last = last_event_for_phase(dir.path(), 4).expect("valid line still found");
234        assert_eq!(last["event"], "workflow_started");
235    }
236
237    #[test]
238    fn describe_prefers_detail_fields() {
239        assert_eq!(
240            describe(&serde_json::json!({"event": "transition", "to": "ship"})),
241            "transition (ship)"
242        );
243        assert_eq!(
244            describe(&serde_json::json!({"event": "workflow_finished"})),
245            "workflow_finished"
246        );
247        assert_eq!(describe(&serde_json::json!({})), "unknown");
248    }
249
250    #[test]
251    fn emit_is_fail_soft_on_unwritable_path() {
252        // A file where the .devflow directory should be makes create_dir_all
253        // fail; emit must not panic.
254        let dir = tempfile::tempdir().unwrap();
255        std::fs::write(dir.path().join(".devflow"), "not a dir").unwrap();
256        emit(dir.path(), 1, "transition", serde_json::Value::Null);
257    }
258}