Skip to main content

plan_issue/tracking/
events.rs

1//! Append-only event journal for tracking runs.
2//!
3//! Schema identifier: `plan-issue.execution-event.v1`. Records run start,
4//! task selection, run updates, validation/review evidence, checkpoint
5//! attempts (dry-run + live), reconciliation outcomes, and failures.
6
7use std::fs::{File, OpenOptions};
8use std::io::{self, BufRead, BufReader, Write};
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::runtime_layout;
15
16pub const EVENT_SCHEMA: &str = "plan-issue.execution-event.v1";
17
18/// Discrete event type written to `events.jsonl`.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ExecutionEventKind {
22    RunStarted,
23    RunUpdated,
24    TaskSelected,
25    PhaseChanged,
26    ValidationRecorded,
27    ReviewRecorded,
28    Reconciled,
29    CheckpointPlanned,
30    CheckpointPosted,
31    CheckpointFailed,
32    BlockerAdded,
33    BlockerCleared,
34    RunCompleted,
35}
36
37impl ExecutionEventKind {
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            Self::RunStarted => "run_started",
41            Self::RunUpdated => "run_updated",
42            Self::TaskSelected => "task_selected",
43            Self::PhaseChanged => "phase_changed",
44            Self::ValidationRecorded => "validation_recorded",
45            Self::ReviewRecorded => "review_recorded",
46            Self::Reconciled => "reconciled",
47            Self::CheckpointPlanned => "checkpoint_planned",
48            Self::CheckpointPosted => "checkpoint_posted",
49            Self::CheckpointFailed => "checkpoint_failed",
50            Self::BlockerAdded => "blocker_added",
51            Self::BlockerCleared => "blocker_cleared",
52            Self::RunCompleted => "run_completed",
53        }
54    }
55}
56
57/// One row in the append-only `events.jsonl` journal.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ExecutionEvent {
60    pub schema: String,
61    pub run_id: String,
62    pub at: String,
63    #[serde(rename = "type")]
64    pub kind: ExecutionEventKind,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub task: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub note: Option<String>,
69    /// Free-form structured detail for the event. Stays a JSON object so the
70    /// schema can grow without rewriting prior events.
71    #[serde(default, skip_serializing_if = "is_null_value")]
72    pub detail: Value,
73}
74
75fn is_null_value(value: &Value) -> bool {
76    value.is_null()
77}
78
79impl ExecutionEvent {
80    pub fn new(run_id: impl Into<String>, kind: ExecutionEventKind, at: impl Into<String>) -> Self {
81        Self {
82            schema: EVENT_SCHEMA.to_string(),
83            run_id: run_id.into(),
84            at: at.into(),
85            kind,
86            task: None,
87            note: None,
88            detail: Value::Null,
89        }
90    }
91
92    pub fn with_task(mut self, task: impl Into<String>) -> Self {
93        self.task = Some(task.into());
94        self
95    }
96
97    pub fn with_note(mut self, note: impl Into<String>) -> Self {
98        self.note = Some(note.into());
99        self
100    }
101
102    pub fn with_detail(mut self, detail: Value) -> Self {
103        self.detail = detail;
104        self
105    }
106}
107
108/// Append an event to `events.jsonl`. Creates parents and the file when
109/// missing; never rewrites prior lines.
110pub fn append_event(path: &Path, event: &ExecutionEvent) -> io::Result<()> {
111    if let Some(parent) = path.parent() {
112        runtime_layout::ensure_dir(parent)?;
113    }
114    let serialized = serde_json::to_string(event)
115        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
116    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
117    file.write_all(serialized.as_bytes())?;
118    file.write_all(b"\n")?;
119    Ok(())
120}
121
122/// Parse every line of `events.jsonl` into an [`ExecutionEvent`]. Skips
123/// empty lines; reports malformed lines via the returned error.
124pub fn read_events(path: &Path) -> io::Result<Vec<ExecutionEvent>> {
125    let file = File::open(path)?;
126    let reader = BufReader::new(file);
127    let mut events = Vec::new();
128    for (lineno, line) in reader.lines().enumerate() {
129        let line = line?;
130        let trimmed = line.trim();
131        if trimmed.is_empty() {
132            continue;
133        }
134        let event: ExecutionEvent = serde_json::from_str(trimmed).map_err(|err| {
135            io::Error::new(
136                io::ErrorKind::InvalidData,
137                format!("events.jsonl line {lineno}: {err}"),
138            )
139        })?;
140        events.push(event);
141    }
142    Ok(events)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use serde_json::json;
149    use tempfile::TempDir;
150
151    #[test]
152    fn tracking_events_round_trip_single_event() {
153        let event = ExecutionEvent::new(
154            "run-1",
155            ExecutionEventKind::RunStarted,
156            "2026-05-26T00:00:00Z",
157        )
158        .with_note("session start");
159        let raw = serde_json::to_string(&event).expect("serialize");
160        let parsed: ExecutionEvent = serde_json::from_str(&raw).expect("parse");
161        assert_eq!(parsed.run_id, "run-1");
162        assert_eq!(parsed.kind, ExecutionEventKind::RunStarted);
163        assert_eq!(parsed.note.as_deref(), Some("session start"));
164    }
165
166    #[test]
167    fn tracking_events_appends_without_rewriting_prior_lines() {
168        let tmp = TempDir::new().expect("tmp");
169        let path = tmp.path().join("events.jsonl");
170        let e1 = ExecutionEvent::new("run-1", ExecutionEventKind::RunStarted, "t1");
171        let e2 = ExecutionEvent::new("run-1", ExecutionEventKind::Reconciled, "t2")
172            .with_detail(json!({"fsm_state": "RECORD_OPEN_ACTIVE"}));
173        let e3 = ExecutionEvent::new("run-1", ExecutionEventKind::CheckpointPosted, "t3")
174            .with_detail(json!({"roles": ["state", "validation"]}));
175        append_event(&path, &e1).expect("append 1");
176        append_event(&path, &e2).expect("append 2");
177        append_event(&path, &e3).expect("append 3");
178
179        let events = read_events(&path).expect("read");
180        assert_eq!(events.len(), 3);
181        assert_eq!(events[0].kind, ExecutionEventKind::RunStarted);
182        assert_eq!(events[1].kind, ExecutionEventKind::Reconciled);
183        assert_eq!(events[2].kind, ExecutionEventKind::CheckpointPosted);
184        assert_eq!(events[2].detail["roles"][0], "state");
185    }
186
187    #[test]
188    fn tracking_events_skips_empty_lines_and_reports_malformed() {
189        let tmp = TempDir::new().expect("tmp");
190        let path = tmp.path().join("events.jsonl");
191        let good = ExecutionEvent::new("run-1", ExecutionEventKind::RunStarted, "t1");
192        append_event(&path, &good).expect("append good");
193        std::fs::OpenOptions::new()
194            .append(true)
195            .open(&path)
196            .expect("open append")
197            .write_all(b"\n   \n")
198            .expect("blank lines");
199        let events = read_events(&path).expect("read");
200        assert_eq!(events.len(), 1);
201
202        // Malformed line should error.
203        std::fs::OpenOptions::new()
204            .append(true)
205            .open(&path)
206            .expect("open append")
207            .write_all(b"not json\n")
208            .expect("bad line");
209        let err = read_events(&path).expect_err("malformed should error");
210        assert!(err.to_string().contains("line"));
211    }
212}