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) = crate::workflow::ensure_devflow_dir(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/// A phase's latest event plus its newest matching `stage_launched`
74/// timestamp, both from the same one-pass read (999.30 / DEN-55 IN-01) —
75/// `devflow status` needs the real stage-entry time (21a) alongside the
76/// last-action line, and previously re-scanned the whole log per phase to
77/// get it (`latest_stage_launched_ts`, now folded in here).
78#[derive(Debug, Clone)]
79pub struct PhaseEventSummary {
80    pub event: serde_json::Value,
81    pub stage_launched_ts: Option<u64>,
82}
83
84/// The most recent event per phase, from ONE read + parse pass over the log
85/// (14-CR-10) — `devflow status` renders N phases without N full-file scans.
86/// Each summary's `stage_launched_ts` is the newest matching `stage_launched`
87/// event's `ts`, independent of what the latest event overall is: a later
88/// `transition`, `gate_fired`, or corrupt line never clears an
89/// already-recorded launch timestamp.
90pub fn last_events_by_phase(
91    project_root: &Path,
92) -> std::collections::HashMap<u32, PhaseEventSummary> {
93    use std::collections::hash_map::Entry;
94
95    let mut latest: std::collections::HashMap<u32, PhaseEventSummary> =
96        std::collections::HashMap::new();
97    let Ok(contents) = std::fs::read_to_string(events_path(project_root)) else {
98        return latest;
99    };
100    for event in contents
101        .lines()
102        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
103    {
104        let Some(phase) = event.get("phase").and_then(|p| p.as_u64()) else {
105            continue;
106        };
107        let phase = phase as u32;
108        let launch_ts = (event.get("event").and_then(|e| e.as_str()) == Some("stage_launched"))
109            .then(|| event.get("ts").and_then(|t| t.as_u64()))
110            .flatten();
111        // Later lines overwrite the latest event by append order; the
112        // launch timestamp only moves forward when THIS line is itself a
113        // valid stage_launched event.
114        match latest.entry(phase) {
115            Entry::Occupied(mut occupied) => {
116                let summary = occupied.get_mut();
117                summary.event = event;
118                if let Some(ts) = launch_ts {
119                    summary.stage_launched_ts = Some(ts);
120                }
121            }
122            Entry::Vacant(vacant) => {
123                vacant.insert(PhaseEventSummary {
124                    event,
125                    stage_launched_ts: launch_ts,
126                });
127            }
128        }
129    }
130    latest
131}
132
133/// Read the last event line recorded for `phase`, if any.
134pub fn last_event_for_phase(project_root: &Path, phase: u32) -> Option<serde_json::Value> {
135    last_events_by_phase(project_root)
136        .remove(&phase)
137        .map(|summary| summary.event)
138}
139
140/// Render an event as a short human-readable summary ("gate_fired (ship)").
141pub fn describe(event: &serde_json::Value) -> String {
142    let kind = event
143        .get("event")
144        .and_then(|e| e.as_str())
145        .unwrap_or("unknown");
146    let detail = ["to", "stage", "status", "hook", "reason"]
147        .iter()
148        .find_map(|key| event.get(*key).and_then(|v| v.as_str()));
149    match detail {
150        Some(detail) => format!("{kind} ({detail})"),
151        None => kind.to_string(),
152    }
153}
154
155fn unix_now() -> u64 {
156    SystemTime::now()
157        .duration_since(UNIX_EPOCH)
158        .map(|d| d.as_secs())
159        .unwrap_or(0)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn read_lines(root: &Path) -> Vec<serde_json::Value> {
167        std::fs::read_to_string(events_path(root))
168            .unwrap_or_default()
169            .lines()
170            .map(|l| serde_json::from_str(l).expect("every line parses as JSON"))
171            .collect()
172    }
173
174    #[test]
175    fn emit_appends_parseable_lines_with_envelope_fields() {
176        let dir = tempfile::tempdir().unwrap();
177        emit(
178            dir.path(),
179            14,
180            "transition",
181            serde_json::json!({"from": "code", "to": "validate"}),
182        );
183        emit(
184            dir.path(),
185            15,
186            "gate_fired",
187            serde_json::json!({"stage": "ship"}),
188        );
189
190        let lines = read_lines(dir.path());
191        assert_eq!(lines.len(), 2);
192        for line in &lines {
193            assert_eq!(line["v"], 1);
194            assert!(line["ts"].as_u64().is_some());
195            assert!(line["phase"].as_u64().is_some());
196            assert!(line["event"].as_str().is_some());
197        }
198        assert_eq!(lines[0]["phase"], 14);
199        assert_eq!(lines[0]["from"], "code");
200        assert_eq!(lines[1]["phase"], 15);
201        assert_eq!(lines[1]["stage"], "ship");
202    }
203
204    #[test]
205    fn emit_never_lets_payload_forge_envelope_keys() {
206        let dir = tempfile::tempdir().unwrap();
207        emit(
208            dir.path(),
209            7,
210            "transition",
211            serde_json::json!({"phase": 99, "event": "forged", "note": "kept"}),
212        );
213
214        let lines = read_lines(dir.path());
215        assert_eq!(lines[0]["phase"], 7, "envelope phase must win");
216        assert_eq!(lines[0]["event"], "transition", "envelope event must win");
217        assert_eq!(lines[0]["note"], "kept");
218    }
219
220    #[test]
221    fn last_event_for_phase_filters_by_phase() {
222        let dir = tempfile::tempdir().unwrap();
223        emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
224        emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
225        emit(
226            dir.path(),
227            1,
228            "transition",
229            serde_json::json!({"to": "plan"}),
230        );
231
232        let last = last_event_for_phase(dir.path(), 1).expect("phase 1 events exist");
233        assert_eq!(last["event"], "transition");
234        let other = last_event_for_phase(dir.path(), 2).expect("phase 2 events exist");
235        assert_eq!(other["event"], "workflow_started");
236        assert!(last_event_for_phase(dir.path(), 3).is_none());
237    }
238
239    /// 14-CR-10: one pass over the log yields every phase's latest event.
240    #[test]
241    fn last_events_by_phase_collects_latest_per_phase_in_one_pass() {
242        let dir = tempfile::tempdir().unwrap();
243        emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
244        emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
245        emit(
246            dir.path(),
247            1,
248            "transition",
249            serde_json::json!({"to": "plan"}),
250        );
251        emit(
252            dir.path(),
253            2,
254            "gate_fired",
255            serde_json::json!({"stage": "ship"}),
256        );
257
258        let latest = last_events_by_phase(dir.path());
259        assert_eq!(latest.len(), 2);
260        assert_eq!(latest[&1].event["event"], "transition");
261        assert_eq!(latest[&2].event["event"], "gate_fired");
262        assert!(last_events_by_phase(&dir.path().join("empty")).is_empty());
263    }
264
265    /// 999.30 / DEN-55 IN-01: the summary's `stage_launched_ts` tracks the
266    /// NEWEST matching `stage_launched` event from the same one-pass read,
267    /// independent of the latest event overall — a later non-launch event
268    /// or a corrupt line must never clear an already-recorded timestamp.
269    #[test]
270    fn last_events_by_phase_tracks_newest_stage_launched_ts_across_the_pass() {
271        let dir = tempfile::tempdir().unwrap();
272
273        // No stage_launched at all yet.
274        emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
275        assert_eq!(last_events_by_phase(dir.path())[&1].stage_launched_ts, None);
276
277        // First stage_launched.
278        emit(
279            dir.path(),
280            1,
281            "stage_launched",
282            serde_json::json!({"stage": "define"}),
283        );
284        let first_ts = last_events_by_phase(dir.path())[&1]
285            .stage_launched_ts
286            .expect("stage_launched recorded a timestamp");
287
288        // A later, unrelated event must not clear the launch timestamp,
289        // even though it becomes the new latest event.
290        emit(
291            dir.path(),
292            1,
293            "transition",
294            serde_json::json!({"to": "plan"}),
295        );
296        let after_transition = last_events_by_phase(dir.path());
297        assert_eq!(after_transition[&1].stage_launched_ts, Some(first_ts));
298        assert_eq!(after_transition[&1].event["event"], "transition");
299
300        // A newer stage_launched wins over the first.
301        emit(
302            dir.path(),
303            1,
304            "stage_launched",
305            serde_json::json!({"stage": "plan"}),
306        );
307        let path = events_path(dir.path());
308        let mut contents = std::fs::read_to_string(&path).unwrap();
309        // A corrupt trailing line must not affect the parsed result.
310        contents.push_str("{truncated\n");
311        std::fs::write(&path, contents).unwrap();
312
313        let latest = last_events_by_phase(dir.path());
314        let newest_ts = latest[&1]
315            .stage_launched_ts
316            .expect("newest stage_launched timestamp present");
317        assert!(newest_ts >= first_ts, "newest launch must not be older");
318        assert_eq!(latest[&1].event["event"], "stage_launched");
319    }
320
321    #[test]
322    fn last_event_skips_corrupt_lines() {
323        let dir = tempfile::tempdir().unwrap();
324        emit(dir.path(), 4, "workflow_started", serde_json::Value::Null);
325        let path = events_path(dir.path());
326        let mut contents = std::fs::read_to_string(&path).unwrap();
327        contents.push_str("{truncated\n");
328        std::fs::write(&path, contents).unwrap();
329
330        let last = last_event_for_phase(dir.path(), 4).expect("valid line still found");
331        assert_eq!(last["event"], "workflow_started");
332    }
333
334    #[test]
335    fn describe_prefers_detail_fields() {
336        assert_eq!(
337            describe(&serde_json::json!({"event": "transition", "to": "ship"})),
338            "transition (ship)"
339        );
340        assert_eq!(
341            describe(&serde_json::json!({"event": "workflow_finished"})),
342            "workflow_finished"
343        );
344        assert_eq!(describe(&serde_json::json!({})), "unknown");
345    }
346
347    #[test]
348    fn emit_is_fail_soft_on_unwritable_path() {
349        // A file where the .devflow directory should be makes create_dir_all
350        // fail; emit must not panic.
351        let dir = tempfile::tempdir().unwrap();
352        std::fs::write(dir.path().join(".devflow"), "not a dir").unwrap();
353        emit(dir.path(), 1, "transition", serde_json::Value::Null);
354    }
355}