1use 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#[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#[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 #[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
108pub 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
122pub 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 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}