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 crate::phase_id::PhaseId;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24use tracing::warn;
25
26/// Path of a project's event log.
27pub fn events_path(project_root: &Path) -> PathBuf {
28    project_root.join(".devflow").join("events.jsonl")
29}
30
31/// Schema version stamped on every line.
32const SCHEMA_VERSION: u32 = 1;
33
34/// Append one event line. `fields` supplies the kind-specific payload and
35/// must be a JSON object (anything else is recorded under a `"data"` key).
36pub fn emit(project_root: &Path, phase: PhaseId, event: &str, fields: serde_json::Value) {
37    let mut line = serde_json::json!({
38        "v": SCHEMA_VERSION,
39        "ts": unix_now(),
40        "phase": phase,
41        "event": event,
42    });
43    match fields {
44        serde_json::Value::Object(map) => {
45            let base = line.as_object_mut().expect("line is an object");
46            for (key, value) in map {
47                // Envelope keys win — a payload must not be able to forge
48                // another phase's identity or a different event kind.
49                base.entry(key).or_insert(value);
50            }
51        }
52        serde_json::Value::Null => {}
53        other => {
54            line["data"] = other;
55        }
56    }
57    let path = events_path(project_root);
58    if let Some(parent) = path.parent()
59        && let Err(err) = crate::workflow::ensure_devflow_dir(parent)
60    {
61        warn!("could not create events dir: {err}");
62        return;
63    }
64    let result = std::fs::OpenOptions::new()
65        .create(true)
66        .append(true)
67        .open(&path)
68        .and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
69    if let Err(err) = result {
70        warn!("could not append to {}: {err}", path.display());
71    }
72}
73
74/// A phase's latest event plus its newest matching `stage_launched`
75/// timestamp, both from the same one-pass read (999.30 / DEN-55 IN-01) —
76/// `devflow status` needs the real stage-entry time (21a) alongside the
77/// last-action line, and previously re-scanned the whole log per phase to
78/// get it (`latest_stage_launched_ts`, now folded in here).
79#[derive(Debug, Clone)]
80pub struct PhaseEventSummary {
81    pub event: serde_json::Value,
82    pub stage_launched_ts: Option<u64>,
83}
84
85/// The most recent event per phase, from ONE read + parse pass over the log
86/// (14-CR-10) — `devflow status` renders N phases without N full-file scans.
87/// Each summary's `stage_launched_ts` is the newest matching `stage_launched`
88/// event's `ts`, independent of what the latest event overall is: a later
89/// `transition`, `gate_fired`, or corrupt line never clears an
90/// already-recorded launch timestamp.
91pub fn last_events_by_phase(
92    project_root: &Path,
93) -> std::collections::HashMap<PhaseId, PhaseEventSummary> {
94    use std::collections::hash_map::Entry;
95
96    let mut latest: std::collections::HashMap<PhaseId, PhaseEventSummary> =
97        std::collections::HashMap::new();
98    let Ok(contents) = std::fs::read_to_string(events_path(project_root)) else {
99        return latest;
100    };
101    for event in contents
102        .lines()
103        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
104    {
105        let Some(phase) = PhaseId::from_json(event.get("phase")) else {
106            continue;
107        };
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: PhaseId) -> Option<serde_json::Value> {
135    last_events_by_phase(project_root)
136        .remove(&phase)
137        .map(|summary| summary.event)
138}
139
140/// Read the last event line for `phase` whose `event` field equals `event`,
141/// if any. Scans the log line by line, parsing each as JSON; an unparsable
142/// line is skipped, not fatal — the log is append-only and a torn final line
143/// must not make the whole history unreadable. A missing file returns `None`.
144///
145/// This is a targeted, single-event-kind scan, distinct from
146/// [`last_events_by_phase`]'s one-pass "latest event of any kind per phase"
147/// read — `ship_evidence::collect` needs the last occurrence of one
148/// *specific* event name, which the latest-of-any-kind index does not
149/// preserve once a later, different event overwrites it.
150pub fn last_event_of_kind_for_phase(
151    project_root: &Path,
152    phase: PhaseId,
153    event: &str,
154) -> Option<serde_json::Value> {
155    let contents = std::fs::read_to_string(events_path(project_root)).ok()?;
156    let mut last = None;
157    for value in contents
158        .lines()
159        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
160    {
161        if phase.matches_json(value.get("phase"))
162            && value.get("event").and_then(|e| e.as_str()) == Some(event)
163        {
164            last = Some(value);
165        }
166    }
167    last
168}
169
170/// Whether `phase` has ever emitted an event named `event`. Implemented as
171/// [`last_event_of_kind_for_phase`]`.is_some()` so there is one scanner, not
172/// two.
173pub fn has_event_for_phase(project_root: &Path, phase: PhaseId, event: &str) -> bool {
174    last_event_of_kind_for_phase(project_root, phase, event).is_some()
175}
176
177/// Render an event as a short human-readable summary ("gate_fired (ship)").
178pub fn describe(event: &serde_json::Value) -> String {
179    let kind = event
180        .get("event")
181        .and_then(|e| e.as_str())
182        .unwrap_or("unknown");
183    let detail = ["to", "stage", "status", "hook", "reason"]
184        .iter()
185        .find_map(|key| event.get(*key).and_then(|v| v.as_str()));
186    match detail {
187        Some(detail) => format!("{kind} ({detail})"),
188        None => kind.to_string(),
189    }
190}
191
192fn unix_now() -> u64 {
193    SystemTime::now()
194        .duration_since(UNIX_EPOCH)
195        .map(|d| d.as_secs())
196        .unwrap_or(0)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn read_lines(root: &Path) -> Vec<serde_json::Value> {
204        std::fs::read_to_string(events_path(root))
205            .unwrap_or_default()
206            .lines()
207            .map(|l| serde_json::from_str(l).expect("every line parses as JSON"))
208            .collect()
209    }
210
211    #[test]
212    fn emit_appends_parseable_lines_with_envelope_fields() {
213        let dir = tempfile::tempdir().unwrap();
214        emit(
215            dir.path(),
216            PhaseId::new(14),
217            "transition",
218            serde_json::json!({"from": "code", "to": "validate"}),
219        );
220        emit(
221            dir.path(),
222            PhaseId::new(15),
223            "gate_fired",
224            serde_json::json!({"stage": "ship"}),
225        );
226
227        let lines = read_lines(dir.path());
228        assert_eq!(lines.len(), 2);
229        for line in &lines {
230            assert_eq!(line["v"], 1);
231            assert!(line["ts"].as_u64().is_some());
232            assert!(line["phase"].as_u64().is_some());
233            assert!(line["event"].as_str().is_some());
234        }
235        assert_eq!(lines[0]["phase"], 14);
236        assert_eq!(lines[0]["from"], "code");
237        assert_eq!(lines[1]["phase"], 15);
238        assert_eq!(lines[1]["stage"], "ship");
239    }
240
241    #[test]
242    fn emit_never_lets_payload_forge_envelope_keys() {
243        let dir = tempfile::tempdir().unwrap();
244        emit(
245            dir.path(),
246            PhaseId::new(7),
247            "transition",
248            serde_json::json!({"phase": 99, "event": "forged", "note": "kept"}),
249        );
250
251        let lines = read_lines(dir.path());
252        assert_eq!(lines[0]["phase"], 7, "envelope phase must win");
253        assert_eq!(lines[0]["event"], "transition", "envelope event must win");
254        assert_eq!(lines[0]["note"], "kept");
255    }
256
257    #[test]
258    fn last_event_for_phase_filters_by_phase() {
259        let dir = tempfile::tempdir().unwrap();
260        emit(
261            dir.path(),
262            PhaseId::new(1),
263            "workflow_started",
264            serde_json::Value::Null,
265        );
266        emit(
267            dir.path(),
268            PhaseId::new(2),
269            "workflow_started",
270            serde_json::Value::Null,
271        );
272        emit(
273            dir.path(),
274            PhaseId::new(1),
275            "transition",
276            serde_json::json!({"to": "plan"}),
277        );
278
279        let last = last_event_for_phase(dir.path(), PhaseId::new(1)).expect("phase 1 events exist");
280        assert_eq!(last["event"], "transition");
281        let other =
282            last_event_for_phase(dir.path(), PhaseId::new(2)).expect("phase 2 events exist");
283        assert_eq!(other["event"], "workflow_started");
284        assert!(last_event_for_phase(dir.path(), PhaseId::new(3)).is_none());
285    }
286
287    /// 14-CR-10: one pass over the log yields every phase's latest event.
288    #[test]
289    fn last_events_by_phase_collects_latest_per_phase_in_one_pass() {
290        let dir = tempfile::tempdir().unwrap();
291        emit(
292            dir.path(),
293            PhaseId::new(1),
294            "workflow_started",
295            serde_json::Value::Null,
296        );
297        emit(
298            dir.path(),
299            PhaseId::new(2),
300            "workflow_started",
301            serde_json::Value::Null,
302        );
303        emit(
304            dir.path(),
305            PhaseId::new(1),
306            "transition",
307            serde_json::json!({"to": "plan"}),
308        );
309        emit(
310            dir.path(),
311            PhaseId::new(2),
312            "gate_fired",
313            serde_json::json!({"stage": "ship"}),
314        );
315
316        let latest = last_events_by_phase(dir.path());
317        assert_eq!(latest.len(), 2);
318        assert_eq!(latest[&PhaseId::new(1)].event["event"], "transition");
319        assert_eq!(latest[&PhaseId::new(2)].event["event"], "gate_fired");
320        assert!(last_events_by_phase(&dir.path().join("empty")).is_empty());
321    }
322
323    /// 999.30 / DEN-55 IN-01: the summary's `stage_launched_ts` tracks the
324    /// NEWEST matching `stage_launched` event from the same one-pass read,
325    /// independent of the latest event overall — a later non-launch event
326    /// or a corrupt line must never clear an already-recorded timestamp.
327    #[test]
328    fn last_events_by_phase_tracks_newest_stage_launched_ts_across_the_pass() {
329        let dir = tempfile::tempdir().unwrap();
330
331        // No stage_launched at all yet.
332        emit(
333            dir.path(),
334            PhaseId::new(1),
335            "workflow_started",
336            serde_json::Value::Null,
337        );
338        assert_eq!(
339            last_events_by_phase(dir.path())[&PhaseId::new(1)].stage_launched_ts,
340            None
341        );
342
343        // First stage_launched.
344        emit(
345            dir.path(),
346            PhaseId::new(1),
347            "stage_launched",
348            serde_json::json!({"stage": "define"}),
349        );
350        let first_ts = last_events_by_phase(dir.path())[&PhaseId::new(1)]
351            .stage_launched_ts
352            .expect("stage_launched recorded a timestamp");
353
354        // A later, unrelated event must not clear the launch timestamp,
355        // even though it becomes the new latest event.
356        emit(
357            dir.path(),
358            PhaseId::new(1),
359            "transition",
360            serde_json::json!({"to": "plan"}),
361        );
362        let after_transition = last_events_by_phase(dir.path());
363        assert_eq!(
364            after_transition[&PhaseId::new(1)].stage_launched_ts,
365            Some(first_ts)
366        );
367        assert_eq!(
368            after_transition[&PhaseId::new(1)].event["event"],
369            "transition"
370        );
371
372        // A newer stage_launched wins over the first.
373        emit(
374            dir.path(),
375            PhaseId::new(1),
376            "stage_launched",
377            serde_json::json!({"stage": "plan"}),
378        );
379        let path = events_path(dir.path());
380        let mut contents = std::fs::read_to_string(&path).unwrap();
381        // A corrupt trailing line must not affect the parsed result.
382        contents.push_str("{truncated\n");
383        std::fs::write(&path, contents).unwrap();
384
385        let latest = last_events_by_phase(dir.path());
386        let newest_ts = latest[&PhaseId::new(1)]
387            .stage_launched_ts
388            .expect("newest stage_launched timestamp present");
389        assert!(newest_ts >= first_ts, "newest launch must not be older");
390        assert_eq!(latest[&PhaseId::new(1)].event["event"], "stage_launched");
391    }
392
393    #[test]
394    fn last_event_skips_corrupt_lines() {
395        let dir = tempfile::tempdir().unwrap();
396        emit(
397            dir.path(),
398            PhaseId::new(4),
399            "workflow_started",
400            serde_json::Value::Null,
401        );
402        let path = events_path(dir.path());
403        let mut contents = std::fs::read_to_string(&path).unwrap();
404        contents.push_str("{truncated\n");
405        std::fs::write(&path, contents).unwrap();
406
407        let last =
408            last_event_for_phase(dir.path(), PhaseId::new(4)).expect("valid line still found");
409        assert_eq!(last["event"], "workflow_started");
410    }
411
412    // These tests deliberately use a generic marker event name rather than
413    // any production event literal, keeping this generic scanner's tests
414    // decoupled from any one caller's naming choice. `ship_evidence::tests`
415    // exercises this same scanner against its real, specific event name.
416    const MARKER_EVENT: &str = "marker_event";
417
418    #[test]
419    fn last_event_of_kind_for_phase_filters_by_phase_and_event_name() {
420        let dir = tempfile::tempdir().unwrap();
421        emit(
422            dir.path(),
423            PhaseId::new(1),
424            "workflow_finished",
425            serde_json::Value::Null,
426        );
427        emit(
428            dir.path(),
429            PhaseId::new(1),
430            MARKER_EVENT,
431            serde_json::json!({"stage": "ship"}),
432        );
433        emit(
434            dir.path(),
435            PhaseId::new(2),
436            MARKER_EVENT,
437            serde_json::json!({"stage": "ship"}),
438        );
439
440        let phase1 = last_event_of_kind_for_phase(dir.path(), PhaseId::new(1), MARKER_EVENT)
441            .expect("phase 1 marker event exists");
442        assert_eq!(phase1["stage"], "ship");
443        assert!(has_event_for_phase(
444            dir.path(),
445            PhaseId::new(1),
446            MARKER_EVENT
447        ));
448        assert!(!has_event_for_phase(
449            dir.path(),
450            PhaseId::new(3),
451            MARKER_EVENT
452        ));
453        assert!(last_event_of_kind_for_phase(dir.path(), PhaseId::new(3), MARKER_EVENT).is_none());
454    }
455
456    #[test]
457    fn last_event_of_kind_for_phase_skips_corrupt_lines() {
458        let dir = tempfile::tempdir().unwrap();
459        emit(
460            dir.path(),
461            PhaseId::new(4),
462            MARKER_EVENT,
463            serde_json::Value::Null,
464        );
465        let path = events_path(dir.path());
466        let mut contents = std::fs::read_to_string(&path).unwrap();
467        contents.push_str("{truncated\n");
468        std::fs::write(&path, contents).unwrap();
469
470        assert!(has_event_for_phase(
471            dir.path(),
472            PhaseId::new(4),
473            MARKER_EVENT
474        ));
475    }
476
477    #[test]
478    fn describe_prefers_detail_fields() {
479        assert_eq!(
480            describe(&serde_json::json!({"event": "transition", "to": "ship"})),
481            "transition (ship)"
482        );
483        assert_eq!(
484            describe(&serde_json::json!({"event": "workflow_finished"})),
485            "workflow_finished"
486        );
487        assert_eq!(describe(&serde_json::json!({})), "unknown");
488    }
489
490    #[test]
491    fn emit_is_fail_soft_on_unwritable_path() {
492        // A file where the .devflow directory should be makes create_dir_all
493        // fail; emit must not panic.
494        let dir = tempfile::tempdir().unwrap();
495        std::fs::write(dir.path().join(".devflow"), "not a dir").unwrap();
496        emit(
497            dir.path(),
498            PhaseId::new(1),
499            "transition",
500            serde_json::Value::Null,
501        );
502    }
503}