Skip to main content

harn_vm/agent_events/
sinks.rs

1use std::sync::{Arc, Mutex};
2
3use serde::{Deserialize, Serialize};
4
5use crate::event_log::{AnyEventLog, EventLog, LogEvent as EventLogRecord, Topic};
6
7use super::AgentEvent;
8
9fn should_persist_event(event: &AgentEvent) -> bool {
10    match event {
11        // Text-mode parsing candidates are live UX signals, not durable tool-call
12        // lifecycle records for replay/audit consumers.
13        AgentEvent::ToolCall { parsing, .. } | AgentEvent::ToolCallUpdate { parsing, .. } => {
14            parsing.is_none()
15        }
16        _ => true,
17    }
18}
19
20/// External consumers of the event stream (e.g. the harn-cli ACP server,
21/// which translates events into JSON-RPC notifications).
22pub trait AgentEventSink: Send + Sync {
23    fn handle_event(&self, event: &AgentEvent);
24}
25
26/// Envelope written to `event_log.jsonl` (#103). Wraps the raw
27/// `AgentEvent` with monotonic index + timestamp + frame depth so
28/// replay engines can reconstruct paused state at any event index,
29/// and scrubber UIs can bucket events by time. The envelope is the
30/// on-disk shape; the wire format for live consumers is still the
31/// raw `AgentEvent` so existing sinks don't churn.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct PersistedAgentEvent {
34    /// Monotonic per-session index starting at 0. Unique within a
35    /// session; gaps never happen even under load because the sink
36    /// owns the counter under a mutex.
37    pub index: u64,
38    /// Milliseconds since the Unix epoch, captured when the sink
39    /// received the event. Not the event's emission time — that
40    /// would require threading a clock through every emit site.
41    pub emitted_at_ms: i64,
42    /// Call-stack depth at the moment of emission, when the caller
43    /// can supply it. `None` for events emitted from a context where
44    /// the VM frame stack isn't available.
45    pub frame_depth: Option<u32>,
46    /// The raw event, flattened so `jq '.type'` works as expected.
47    #[serde(flatten)]
48    pub event: AgentEvent,
49}
50
51/// Append-only JSONL sink for a single session's event stream (#103).
52/// One writer per session; sinks rotate to a numbered suffix when a
53/// running file crosses `ROTATE_BYTES` (100 MB today — long chat
54/// sessions rarely exceed 5 MB, so rotation almost never fires).
55pub struct JsonlEventSink {
56    state: Mutex<JsonlEventSinkState>,
57    base_path: std::path::PathBuf,
58}
59
60struct JsonlEventSinkState {
61    writer: std::io::BufWriter<std::fs::File>,
62    index: u64,
63    bytes_written: u64,
64    rotation: u32,
65}
66
67impl JsonlEventSink {
68    /// Hard cap past which the current file rotates to a numbered
69    /// suffix (`event_log-000001.jsonl`). Chosen so long debugging
70    /// sessions don't produce unreadable multi-GB logs.
71    pub const ROTATE_BYTES: u64 = 100 * 1024 * 1024;
72
73    /// Open a new sink writing to `base_path`. Creates parent dirs
74    /// if missing. Overwrites an existing file so each fresh session
75    /// starts from index 0.
76    pub fn open(base_path: impl Into<std::path::PathBuf>) -> std::io::Result<Arc<Self>> {
77        let base_path = base_path.into();
78        if let Some(parent) = base_path.parent() {
79            std::fs::create_dir_all(parent)?;
80        }
81        let file = std::fs::OpenOptions::new()
82            .create(true)
83            .truncate(true)
84            .write(true)
85            .open(&base_path)?;
86        Ok(Arc::new(Self {
87            state: Mutex::new(JsonlEventSinkState {
88                writer: std::io::BufWriter::new(file),
89                index: 0,
90                bytes_written: 0,
91                rotation: 0,
92            }),
93            base_path,
94        }))
95    }
96
97    /// Flush any buffered writes. Called on session shutdown; the
98    /// Drop impl calls this too but on early panic it may not run.
99    pub fn flush(&self) -> std::io::Result<()> {
100        use std::io::Write as _;
101        self.state
102            .lock()
103            .expect("jsonl sink mutex poisoned")
104            .writer
105            .flush()
106    }
107
108    /// Current event index — primarily for tests and the "how many
109    /// events are in this run" run-record summary.
110    pub fn event_count(&self) -> u64 {
111        self.state.lock().expect("jsonl sink mutex poisoned").index
112    }
113
114    fn rotate_if_needed(&self, state: &mut JsonlEventSinkState) -> std::io::Result<()> {
115        use std::io::Write as _;
116        if state.bytes_written < Self::ROTATE_BYTES {
117            return Ok(());
118        }
119        state.writer.flush()?;
120        state.rotation += 1;
121        let suffix = format!("-{:06}", state.rotation);
122        let rotated = self.base_path.with_file_name({
123            let stem = self
124                .base_path
125                .file_stem()
126                .and_then(|s| s.to_str())
127                .unwrap_or("event_log");
128            let ext = self
129                .base_path
130                .extension()
131                .and_then(|e| e.to_str())
132                .unwrap_or("jsonl");
133            format!("{stem}{suffix}.{ext}")
134        });
135        let file = std::fs::OpenOptions::new()
136            .create(true)
137            .truncate(true)
138            .write(true)
139            .open(&rotated)?;
140        state.writer = std::io::BufWriter::new(file);
141        state.bytes_written = 0;
142        Ok(())
143    }
144}
145
146/// Event-log-backed sink for a single session's agent event stream.
147/// Uses the generalized append-only event log when one is installed for
148/// the current VM thread and falls back to `JsonlEventSink` only for
149/// older env-driven workflows.
150pub struct EventLogSink {
151    log: Arc<AnyEventLog>,
152    topic: Topic,
153    session_id: String,
154}
155
156impl EventLogSink {
157    pub fn new(log: Arc<AnyEventLog>, session_id: impl Into<String>) -> Arc<Self> {
158        let session_id = session_id.into();
159        let topic = Topic::new(format!(
160            "observability.agent_events.{}",
161            crate::event_log::sanitize_topic_component(&session_id)
162        ))
163        .expect("session id should sanitize to a valid topic");
164        Arc::new(Self {
165            log,
166            topic,
167            session_id,
168        })
169    }
170}
171
172impl AgentEventSink for JsonlEventSink {
173    fn handle_event(&self, event: &AgentEvent) {
174        if !should_persist_event(event) {
175            return;
176        }
177        use std::io::Write as _;
178        let mut state = self.state.lock().expect("jsonl sink mutex poisoned");
179        let index = state.index;
180        state.index += 1;
181        let emitted_at_ms = std::time::SystemTime::now()
182            .duration_since(std::time::UNIX_EPOCH)
183            .map(|d| d.as_millis() as i64)
184            .unwrap_or(0);
185        let envelope = PersistedAgentEvent {
186            index,
187            emitted_at_ms,
188            frame_depth: None,
189            event: event.clone(),
190        };
191        let Ok(mut envelope_json) = serde_json::to_value(&envelope) else {
192            return;
193        };
194        crate::redact::current_policy().redact_json_in_place(&mut envelope_json);
195        if let Ok(line) = serde_json::to_string(&envelope_json) {
196            // One line, newline-terminated — JSON Lines spec.
197            // Errors here are swallowed on purpose; a failing write
198            // must never crash the agent loop, and the run record
199            // itself is a secondary artifact.
200            if state
201                .writer
202                .write_all(line.as_bytes())
203                .and_then(|_| state.writer.write_all(b"\n"))
204                .is_ok()
205            {
206                state.bytes_written += line.len() as u64 + 1;
207                let _ = state.writer.flush();
208                let _ = self.rotate_if_needed(&mut state);
209            }
210        }
211    }
212}
213
214impl AgentEventSink for EventLogSink {
215    fn handle_event(&self, event: &AgentEvent) {
216        if !should_persist_event(event) {
217            return;
218        }
219        let event_json = match serde_json::to_value(event) {
220            Ok(value) => value,
221            Err(_) => return,
222        };
223        let event_kind = event_json
224            .get("type")
225            .and_then(|value| value.as_str())
226            .unwrap_or("agent_event")
227            .to_string();
228        let payload = serde_json::json!({
229            "index_hint": now_ms(),
230            "session_id": self.session_id,
231            "event": event_json,
232        });
233        let mut headers = std::collections::BTreeMap::new();
234        headers.insert("session_id".to_string(), self.session_id.clone());
235        let log = self.log.clone();
236        let topic = self.topic.clone();
237        let mut record = EventLogRecord::new(event_kind, payload).with_headers(headers);
238        record.redact_in_place(&crate::redact::current_policy());
239        if let Ok(handle) = tokio::runtime::Handle::try_current() {
240            handle.spawn(async move {
241                let _ = log.append(&topic, record).await;
242            });
243        } else {
244            let _ = futures::executor::block_on(log.append(&topic, record));
245        }
246    }
247}
248
249impl Drop for JsonlEventSink {
250    fn drop(&mut self) {
251        if let Ok(mut state) = self.state.lock() {
252            use std::io::Write as _;
253            let _ = state.writer.flush();
254        }
255    }
256}
257
258/// Fan-out helper for composing multiple external sinks.
259pub struct MultiSink {
260    sinks: Mutex<Vec<Arc<dyn AgentEventSink>>>,
261}
262
263impl MultiSink {
264    pub fn new() -> Self {
265        Self {
266            sinks: Mutex::new(Vec::new()),
267        }
268    }
269    pub fn push(&self, sink: Arc<dyn AgentEventSink>) {
270        self.sinks.lock().expect("sink mutex poisoned").push(sink);
271    }
272    pub fn len(&self) -> usize {
273        self.sinks.lock().expect("sink mutex poisoned").len()
274    }
275    pub fn is_empty(&self) -> bool {
276        self.len() == 0
277    }
278}
279
280impl Default for MultiSink {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286impl AgentEventSink for MultiSink {
287    fn handle_event(&self, event: &AgentEvent) {
288        // Deliberate: snapshot then release the lock before invoking sink
289        // callbacks. Sinks can re-enter the event system (e.g. a host
290        // sink that logs to another AgentEvent path), so holding the
291        // mutex across the callback would risk self-deadlock. Arc clones
292        // are refcount bumps — cheap.
293        let sinks = self.sinks.lock().expect("sink mutex poisoned").clone();
294        for sink in sinks {
295            sink.handle_event(event);
296        }
297    }
298}
299
300pub(super) fn now_ms() -> i64 {
301    std::time::SystemTime::now()
302        .duration_since(std::time::UNIX_EPOCH)
303        .map(|duration| duration.as_millis() as i64)
304        .unwrap_or(0)
305}