1use serde::Serialize;
7use std::fs::{self, OpenOptions};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Serialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum Event {
17 PlanStart {
18 plan_name: String,
19 phase_count: usize,
20 },
21 PhaseStart {
22 phase_id: String,
23 attempt: u32,
24 },
25 PhasePassed {
26 phase_id: String,
27 attempt: u32,
28 duration_ms: u64,
29 cost_usd: Option<f64>,
30 },
31 PhaseFailed {
32 phase_id: String,
33 attempt: u32,
34 duration_ms: u64,
35 error: String,
36 },
37 PhaseSkipped {
38 phase_id: String,
39 reason: String,
40 },
41 PlanCompleted {
42 phases_passed: usize,
43 total_cost_usd: f64,
44 },
45 PlanAborted {
46 phases_passed: usize,
47 phases_pending: usize,
48 },
49}
50
51#[derive(Debug, Serialize)]
53pub struct FullEvent {
54 pub seq: u32,
55 pub ts: String,
56 #[serde(flatten)]
57 pub event: Event,
58}
59
60pub struct EventLogger {
64 jsonl_path: PathBuf,
65 seq: u32,
66 stdout_json: bool,
67}
68
69impl EventLogger {
70 pub fn new(cwd: &Path, plan_name: &str) -> Self {
72 let jsonl_path = cwd
73 .join(".edda")
74 .join("conductor")
75 .join(plan_name)
76 .join("events.jsonl");
77 Self {
78 jsonl_path,
79 seq: 0,
80 stdout_json: false,
81 }
82 }
83
84 pub fn with_stdout_json(mut self, enabled: bool) -> Self {
86 self.stdout_json = enabled;
87 self
88 }
89
90 pub fn record(&mut self, event: Event) {
92 let full = FullEvent {
93 seq: self.seq,
94 ts: now_rfc3339(),
95 event,
96 };
97 self.seq += 1;
98
99 if let Ok(line) = serde_json::to_string(&full) {
100 let _ = append_line(&self.jsonl_path, &line);
101 if self.stdout_json {
102 let _ = writeln!(std::io::stdout(), "{line}");
103 }
104 }
105 }
106}
107
108fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
110 if let Some(parent) = path.parent() {
111 fs::create_dir_all(parent)?;
112 }
113 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
114 writeln!(file, "{line}")
115}
116
117#[derive(Debug, Serialize)]
121pub struct RunnerStatus {
122 pub plan: String,
123 pub status: String,
124 pub current_phase: Option<String>,
125 pub completed: Vec<String>,
126 pub failed: Vec<String>,
127 pub updated_at: String,
128}
129
130pub fn write_runner_status(
132 cwd: &Path,
133 state: &crate::state::machine::PlanState,
134 current_phase: Option<&str>,
135) {
136 use crate::state::machine::PhaseStatus;
137
138 let status = RunnerStatus {
139 plan: state.plan_name.clone(),
140 status: format!("{:?}", state.plan_status).to_lowercase(),
141 current_phase: current_phase.map(String::from),
142 completed: state
143 .phases
144 .iter()
145 .filter(|p| p.status == PhaseStatus::Passed)
146 .map(|p| p.id.clone())
147 .collect(),
148 failed: state
149 .phases
150 .iter()
151 .filter(|p| p.status == PhaseStatus::Failed || p.status == PhaseStatus::Stale)
152 .map(|p| p.id.clone())
153 .collect(),
154 updated_at: now_rfc3339(),
155 };
156
157 let path = cwd
158 .join(".edda")
159 .join("conductor")
160 .join(&state.plan_name)
161 .join("runner-status.json");
162
163 if let Ok(data) = serde_json::to_string_pretty(&status) {
164 let _ = edda_store::write_atomic(&path, data.as_bytes());
165 }
166}
167
168fn now_rfc3339() -> String {
169 time::OffsetDateTime::now_utc()
170 .format(&time::format_description::well_known::Rfc3339)
171 .unwrap_or_default()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn event_plan_start_serialization() {
180 let event = Event::PlanStart {
181 plan_name: "test".into(),
182 phase_count: 3,
183 };
184 let json = serde_json::to_string(&event).unwrap();
185 assert!(json.contains(r#""type":"plan_start""#));
186 assert!(json.contains(r#""plan_name":"test""#));
187 assert!(json.contains(r#""phase_count":3"#));
188 }
189
190 #[test]
191 fn event_phase_passed_serialization() {
192 let event = Event::PhasePassed {
193 phase_id: "build".into(),
194 attempt: 1,
195 duration_ms: 5000,
196 cost_usd: Some(0.42),
197 };
198 let json = serde_json::to_string(&event).unwrap();
199 assert!(json.contains(r#""type":"phase_passed""#));
200 assert!(json.contains(r#""cost_usd":0.42"#));
201 }
202
203 #[test]
204 fn full_event_includes_seq_and_ts() {
205 let full = FullEvent {
206 seq: 5,
207 ts: "2026-02-18T10:00:00Z".into(),
208 event: Event::PhaseStart {
209 phase_id: "lint".into(),
210 attempt: 1,
211 },
212 };
213 let json = serde_json::to_string(&full).unwrap();
214 assert!(json.contains(r#""seq":5"#));
215 assert!(json.contains(r#""ts":"2026-02-18T10:00:00Z""#));
216 assert!(json.contains(r#""type":"phase_start""#));
217 }
218
219 #[test]
220 fn event_logger_creates_and_appends() {
221 let dir = tempfile::tempdir().unwrap();
222 let mut logger = EventLogger::new(dir.path(), "test-plan");
223
224 logger.record(Event::PlanStart {
225 plan_name: "test-plan".into(),
226 phase_count: 2,
227 });
228 logger.record(Event::PhaseStart {
229 phase_id: "a".into(),
230 attempt: 1,
231 });
232
233 let content = std::fs::read_to_string(&logger.jsonl_path).unwrap();
234 let lines: Vec<&str> = content.trim().lines().collect();
235 assert_eq!(lines.len(), 2);
236
237 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
239 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
240 assert_eq!(first["seq"], 0);
241 assert_eq!(second["seq"], 1);
242 assert_eq!(first["type"], "plan_start");
243 assert_eq!(second["type"], "phase_start");
244 }
245
246 #[test]
247 fn event_logger_with_stdout_json_builder() {
248 let dir = tempfile::tempdir().unwrap();
249 let logger = EventLogger::new(dir.path(), "test-plan").with_stdout_json(true);
250 assert!(logger.stdout_json);
251
252 let logger2 = EventLogger::new(dir.path(), "test-plan").with_stdout_json(false);
253 assert!(!logger2.stdout_json);
254 }
255
256 #[test]
257 fn event_logger_default_no_stdout_json() {
258 let dir = tempfile::tempdir().unwrap();
259 let logger = EventLogger::new(dir.path(), "test-plan");
260 assert!(!logger.stdout_json);
261 }
262
263 #[test]
264 fn runner_status_serialization() {
265 let status = RunnerStatus {
266 plan: "my-plan".into(),
267 status: "running".into(),
268 current_phase: Some("build".into()),
269 completed: vec!["lint".into()],
270 failed: vec![],
271 updated_at: "2026-02-18T10:00:00Z".into(),
272 };
273 let json = serde_json::to_string_pretty(&status).unwrap();
274 assert!(json.contains(r#""plan": "my-plan""#));
275 assert!(json.contains(r#""current_phase": "build""#));
276 assert!(json.contains(r#""completed""#));
277 }
278}