Skip to main content

edda_conductor/runner/
event_log.rs

1//! Structured event logging for conductor runs.
2//!
3//! Writes append-only JSONL to `.edda/conductor/{plan}/events.jsonl`.
4//! Independent of edda/edda — works even if edda CLI is not installed.
5
6use serde::Serialize;
7use std::fs::{self, OpenOptions};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11// ── Event types ──
12
13/// A conductor event. Serialized as tagged JSON (`"type": "plan_start"`, etc.).
14#[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/// Wrapper that adds sequence number and timestamp to each event.
52#[derive(Debug, Serialize)]
53pub struct FullEvent {
54    pub seq: u32,
55    pub ts: String,
56    #[serde(flatten)]
57    pub event: Event,
58}
59
60// ── EventLogger ──
61
62/// Append-only JSONL event writer.
63pub struct EventLogger {
64    jsonl_path: PathBuf,
65    seq: u32,
66    stdout_json: bool,
67}
68
69impl EventLogger {
70    /// Create a new logger. Path: `{cwd}/.edda/conductor/{plan_name}/events.jsonl`.
71    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    /// Enable tee-ing events to stdout as JSONL (for `--json` mode).
85    pub fn with_stdout_json(mut self, enabled: bool) -> Self {
86        self.stdout_json = enabled;
87        self
88    }
89
90    /// Record an event. Best-effort: silently ignores write failures.
91    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
108/// Append a single line to a file, creating parent dirs if needed.
109fn 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// ── RunnerStatus ──
118
119/// Lightweight status file for external tools to poll.
120#[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
130/// Derive runner status from current PlanState and write to disk.
131pub 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        // Verify seq increments
238        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}