Skip to main content

fno_agents/
events.rs

1//! Operator-facing `events.jsonl` emitter (Wave 3, task 3.2).
2//!
3//! The boundary (cross-language coupling discipline): `events.jsonl` is
4//! **operator-facing** (what an auditor / the stop hook sees); per-agent
5//! `timeline.jsonl` is **model-facing** (what the model would see in its
6//! transcript). This module owns the operator side.
7//!
8//! Two invariants are load-bearing and tested here:
9//!
10//! - **500B payload cap** (Silent-Failure-Hunter finding): a payload whose
11//!   serialized JSON object exceeds [`MAX_EVENT_PAYLOAD_BYTES`] is REJECTED at
12//!   the write boundary and replaced by a small `event_payload_too_large`
13//!   meta-event. An oversized event must never silently truncate or vanish.
14//! - **FIFO per-emitter ordering**: each emission is open-`O_APPEND`-write-close.
15//!   A single event line stays well under `PIPE_BUF` (4096B; the cap keeps it
16//!   under 600B with the `ts`/`type`/`data` framing), so the append is atomic at
17//!   the kernel level. Cross-emitter ordering (Python <-> Rust interleaving) is
18//!   unspecified by design; consumers filter by `source` when ordering matters.
19//!
20//! Envelope (x-2901): the unified line is `{ts, type, source, data:{...}}` -
21//! the same shape the Python/fno emitter and the Rust loop runtime already
22//! write. The retired `{ts, kind, <flat fields>}` shape is read-tolerated by
23//! `subscribe`/`digest` during the mixed-binary window; nothing emits it here.
24
25use serde::Serialize;
26use serde_json::{Map, Value};
27use std::fs::OpenOptions;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30
31/// Maximum serialized size (bytes) of an event's payload object. Payloads over
32/// this are rejected and replaced by a meta-event. Chosen per the design's
33/// Silent-Failure-Hunter table; keeps the final line under `PIPE_BUF`.
34pub const MAX_EVENT_PAYLOAD_BYTES: usize = 500;
35
36/// Rotate `events.jsonl` once it exceeds this many bytes. The active file is
37/// renamed to `events.jsonl.1` (single generation; older history is the
38/// operator's archive concern, not the daemon's).
39pub const ROTATE_AT_BYTES: u64 = 8 * 1024 * 1024;
40
41/// Errors the emitter surfaces to its caller. Emission failures are logged by
42/// the daemon rather than aborting the operation that triggered them: a missing
43/// audit line must not take down a live agent.
44#[derive(Debug, thiserror::Error)]
45pub enum EmitError {
46    #[error("event io error: {0}")]
47    Io(#[from] std::io::Error),
48    #[error("event payload was not a JSON object")]
49    NotAnObject,
50}
51
52/// Appends structured events to a JSONL file. Cheap to clone (just a path); the
53/// emitter holds no long-lived file descriptor, so rotation cannot strand a
54/// stale fd (Concurrency invariant: "open with `O_APPEND` per emission").
55#[derive(Debug, Clone)]
56pub struct EventEmitter {
57    path: PathBuf,
58    source: String,
59}
60
61impl EventEmitter {
62    /// Construct an emitter writing to `path`, tagging every line with
63    /// `source` (e.g. `"daemon"`, `"worker:wkA"`) so consumers can filter by
64    /// emitter when cross-emitter ordering matters.
65    pub fn new(path: impl Into<PathBuf>, source: impl Into<String>) -> Self {
66        EventEmitter {
67            path: path.into(),
68            source: source.into(),
69        }
70    }
71
72    /// Emit `kind` with a structured payload. The payload must serialize to a
73    /// JSON object; the emitter frames it as `{ts, type: kind, source, data}`
74    /// (wall-clock RFC3339 `ts`), so payload keys live under `data` and never
75    /// collide with the envelope fields.
76    ///
77    /// Returns `Ok(())` on a successful append. An oversized payload is NOT an
78    /// error to the caller: the meta-event is written and `Ok(())` returned, so
79    /// callers cannot accidentally treat "too large" as "not emitted".
80    pub fn emit<P: Serialize>(&self, kind: &str, payload: &P) -> Result<(), EmitError> {
81        let value = serde_json::to_value(payload).map_err(|_| EmitError::NotAnObject)?;
82        let obj = match value {
83            Value::Object(m) => m,
84            Value::Null => Map::new(),
85            _ => return Err(EmitError::NotAnObject),
86        };
87
88        // Size the payload object (sans framing) against the cap. Oversized ->
89        // substitute a small meta-event that records the intent and size, so an
90        // auditor sees that an event was dropped and why, never silence.
91        let payload_len = serde_json::to_string(&obj).map(|s| s.len()).unwrap_or(0);
92        if payload_len > MAX_EVENT_PAYLOAD_BYTES {
93            let mut meta = Map::new();
94            meta.insert("intended_kind".into(), Value::String(kind.to_string()));
95            meta.insert("size".into(), Value::Number(payload_len.into()));
96            return self.write_line("event_payload_too_large", meta);
97        }
98
99        self.write_line(kind, obj)
100    }
101
102    /// Emit an event whose payload is built ad-hoc as a JSON object. Convenience
103    /// for call sites that assemble fields inline rather than via a struct.
104    pub fn emit_fields(&self, kind: &str, fields: Map<String, Value>) -> Result<(), EmitError> {
105        let payload_len = serde_json::to_string(&fields).map(|s| s.len()).unwrap_or(0);
106        if payload_len > MAX_EVENT_PAYLOAD_BYTES {
107            let mut meta = Map::new();
108            meta.insert("intended_kind".into(), Value::String(kind.to_string()));
109            meta.insert("size".into(), Value::Number(payload_len.into()));
110            return self.write_line("event_payload_too_large", meta);
111        }
112        self.write_line(kind, fields)
113    }
114
115    fn write_line(&self, event_type: &str, payload: Map<String, Value>) -> Result<(), EmitError> {
116        // Unified envelope (x-2901): the payload nests under `data`, the kind is
117        // stamped as `type`. The 500B cap is measured on `payload` before this
118        // framing (in emit/emit_fields), so nesting never changes which events
119        // are dropped.
120        let mut obj = Map::new();
121        obj.insert("ts".into(), Value::String(now_rfc3339()));
122        obj.insert("type".into(), Value::String(event_type.to_string()));
123        obj.insert("source".into(), Value::String(self.source.clone()));
124        obj.insert("data".into(), Value::Object(payload));
125        let mut line = serde_json::to_string(&Value::Object(obj))
126            .map_err(|e| EmitError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
127        line.push('\n');
128
129        self.maybe_rotate()?;
130        if let Some(parent) = self.path.parent() {
131            std::fs::create_dir_all(parent)?;
132        }
133        // O_APPEND open-write-close: the append is atomic for a sub-PIPE_BUF
134        // line, so concurrent emitters never interleave a single line.
135        let mut f = OpenOptions::new()
136            .create(true)
137            .append(true)
138            .open(&self.path)?;
139        f.write_all(line.as_bytes())?;
140        Ok(())
141    }
142
143    /// Rename the active file aside once it grows past [`ROTATE_AT_BYTES`].
144    /// Best-effort: a rotation race (two emitters both seeing the file large)
145    /// is harmless because the rename is idempotent at the path level and the
146    /// next `open(..., append)` recreates the active file.
147    fn maybe_rotate(&self) -> Result<(), EmitError> {
148        let size = match std::fs::metadata(&self.path) {
149            Ok(m) => m.len(),
150            Err(_) => return Ok(()), // not yet created; nothing to rotate
151        };
152        if size <= ROTATE_AT_BYTES {
153            return Ok(());
154        }
155        let rotated = rotated_path(&self.path);
156        // Ignore a rename failure (another emitter already rotated): the goal is
157        // bounded file size, not exclusive rotation ownership.
158        let _ = std::fs::rename(&self.path, rotated);
159        Ok(())
160    }
161
162    /// Path this emitter writes to (test/inspection helper).
163    pub fn path(&self) -> &Path {
164        &self.path
165    }
166}
167
168fn rotated_path(path: &Path) -> PathBuf {
169    let mut s = path.as_os_str().to_os_string();
170    s.push(".1");
171    PathBuf::from(s)
172}
173
174/// Wall-clock timestamp in RFC3339 with millisecond precision and a `Z` suffix.
175/// Event `ts` is wall-clock for human audit (drive-window math uses the
176/// monotonic clock instead; LD17). Implemented without `chrono` to keep the
177/// dependency surface minimal.
178pub(crate) fn now_rfc3339() -> String {
179    use std::time::{SystemTime, UNIX_EPOCH};
180    let dur = SystemTime::now()
181        .duration_since(UNIX_EPOCH)
182        .unwrap_or_default();
183    let secs = dur.as_secs();
184    let millis = dur.subsec_millis();
185    let (year, month, day, hour, min, sec) = civil_from_unix(secs);
186    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{millis:03}Z")
187}
188
189/// Convert unix seconds (UTC) to civil (Y, M, D, h, m, s). Uses Howard Hinnant's
190/// days_from_civil inverse; correct for all dates this daemon will ever stamp.
191fn civil_from_unix(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
192    let days = (secs / 86_400) as i64;
193    let rem = secs % 86_400;
194    let hour = (rem / 3600) as u32;
195    let min = ((rem % 3600) / 60) as u32;
196    let sec = (rem % 60) as u32;
197
198    // days since 1970-01-01 -> civil date (Hinnant's algorithm).
199    let z = days + 719_468;
200    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
201    let doe = z - era * 146_097; // [0, 146096]
202    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
203    let y = yoe + era * 400;
204    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
205    let mp = (5 * doy + 2) / 153; // [0, 11]
206    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
207    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
208    let year = if m <= 2 { y + 1 } else { y };
209    (year, m, d, hour, min, sec)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    fn temp_events_path(tag: &str) -> PathBuf {
218        let mut p = std::env::temp_dir();
219        p.push(format!(
220            "fno-agents-events-test-{}-{}-{}.jsonl",
221            tag,
222            std::process::id(),
223            // nanos for uniqueness across same-pid tests
224            std::time::SystemTime::now()
225                .duration_since(std::time::UNIX_EPOCH)
226                .unwrap()
227                .as_nanos()
228        ));
229        p
230    }
231
232    fn read_lines(path: &Path) -> Vec<Value> {
233        std::fs::read_to_string(path)
234            .unwrap_or_default()
235            .lines()
236            .map(|l| serde_json::from_str::<Value>(l).expect("each line is valid json"))
237            .collect()
238    }
239
240    #[test]
241    fn emits_line_with_ts_type_source_data() {
242        let path = temp_events_path("basic");
243        let em = EventEmitter::new(&path, "daemon");
244        em.emit("daemon_started", &json!({"pid": 4242, "version": "0.1.0"}))
245            .unwrap();
246
247        let lines = read_lines(&path);
248        assert_eq!(lines.len(), 1);
249        let l = &lines[0];
250        assert_eq!(l["type"], "daemon_started");
251        assert_eq!(l["source"], "daemon");
252        assert_eq!(l["data"]["pid"], 4242);
253        assert!(l.get("kind").is_none(), "no legacy kind field");
254        assert!(l["ts"].as_str().unwrap().ends_with('Z'));
255        assert!(l["ts"].as_str().unwrap().starts_with("20"));
256        std::fs::remove_file(&path).ok();
257    }
258
259    #[test]
260    fn oversized_payload_becomes_meta_event_not_silence() {
261        let path = temp_events_path("oversize");
262        let em = EventEmitter::new(&path, "daemon");
263        let huge = "x".repeat(2000);
264        em.emit("agent_spawned", &json!({"blob": huge})).unwrap();
265
266        let lines = read_lines(&path);
267        assert_eq!(lines.len(), 1, "exactly one line: the meta-event");
268        let l = &lines[0];
269        assert_eq!(l["type"], "event_payload_too_large");
270        assert_eq!(l["data"]["intended_kind"], "agent_spawned");
271        assert!(l["data"]["size"].as_u64().unwrap() > MAX_EVENT_PAYLOAD_BYTES as u64);
272        std::fs::remove_file(&path).ok();
273    }
274
275    #[test]
276    fn appends_preserve_fifo_order() {
277        let path = temp_events_path("fifo");
278        let em = EventEmitter::new(&path, "daemon");
279        for i in 0..10 {
280            em.emit("tick", &json!({"seq": i})).unwrap();
281        }
282        let lines = read_lines(&path);
283        let seqs: Vec<u64> = lines
284            .iter()
285            .map(|l| l["data"]["seq"].as_u64().unwrap())
286            .collect();
287        assert_eq!(seqs, (0..10).collect::<Vec<_>>());
288        std::fs::remove_file(&path).ok();
289    }
290
291    #[test]
292    fn null_payload_is_allowed_as_empty_object() {
293        let path = temp_events_path("null");
294        let em = EventEmitter::new(&path, "worker:wkA");
295        em.emit("heartbeat", &Value::Null).unwrap();
296        let lines = read_lines(&path);
297        assert_eq!(lines[0]["type"], "heartbeat");
298        assert_eq!(lines[0]["source"], "worker:wkA");
299        assert_eq!(lines[0]["data"], json!({}));
300        std::fs::remove_file(&path).ok();
301    }
302
303    #[test]
304    fn civil_date_matches_known_epoch_points() {
305        // 0 -> 1970-01-01T00:00:00
306        assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
307        // 1700000000 -> 2023-11-14T22:13:20 UTC (known fixture)
308        assert_eq!(civil_from_unix(1_700_000_000), (2023, 11, 14, 22, 13, 20));
309    }
310}