Skip to main content

harn_vm/agent_events/
sinks.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::{Arc, Mutex};
4
5use serde::{Deserialize, Serialize};
6
7use crate::event_log::{AnyEventLog, EventLog, LogEvent as EventLogRecord, Topic};
8
9use super::AgentEvent;
10
11/// External consumers of the event stream (e.g. the harn-cli ACP server,
12/// which translates events into JSON-RPC notifications).
13pub trait AgentEventSink: Send + Sync {
14    fn handle_event(&self, event: &AgentEvent);
15
16    /// Wait until every event accepted before this call has reached the sink's
17    /// durable boundary. Synchronous sinks are complete when `handle_event`
18    /// returns, so their default barrier is immediately ready.
19    fn flush(&self) -> AgentEventSinkFlush<'_> {
20        Box::pin(async { Ok(()) })
21    }
22}
23
24pub type AgentEventSinkFlush<'a> =
25    Pin<Box<dyn Future<Output = Result<(), AgentEventSinkError>> + Send + 'a>>;
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct AgentEventSinkError {
29    sink: &'static str,
30    message: String,
31    dropped_events: u64,
32}
33
34impl AgentEventSinkError {
35    pub fn new(sink: &'static str, error: impl std::fmt::Display) -> Self {
36        Self {
37            sink,
38            message: error.to_string(),
39            dropped_events: 0,
40        }
41    }
42
43    fn dropped_event(sink: &'static str, error: impl std::fmt::Display) -> Self {
44        Self {
45            sink,
46            message: error.to_string(),
47            dropped_events: 1,
48        }
49    }
50
51    pub fn sink(&self) -> &'static str {
52        self.sink
53    }
54
55    pub fn message(&self) -> &str {
56        &self.message
57    }
58
59    /// Number of emitted events known not to have crossed the durable boundary.
60    pub fn dropped_events(&self) -> u64 {
61        self.dropped_events
62    }
63}
64
65impl std::fmt::Display for AgentEventSinkError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{} sink flush failed: {}", self.sink, self.message)?;
68        if self.dropped_events > 0 {
69            write!(f, " ({} dropped events)", self.dropped_events)?;
70        }
71        Ok(())
72    }
73}
74
75impl std::error::Error for AgentEventSinkError {}
76
77fn record_sink_failure(first_error: &mut Option<AgentEventSinkError>, error: AgentEventSinkError) {
78    if let Some(first) = first_error {
79        first.dropped_events = first.dropped_events.saturating_add(error.dropped_events);
80    } else {
81        *first_error = Some(error);
82    }
83}
84
85/// Envelope written to `event_log.jsonl` (#103). Wraps the raw
86/// `AgentEvent` with monotonic index + timestamp + frame depth so
87/// replay engines can reconstruct paused state at any event index,
88/// and scrubber UIs can bucket events by time. The envelope is the
89/// on-disk shape; the wire format for live consumers is still the
90/// raw `AgentEvent` so existing sinks don't churn.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct PersistedAgentEvent {
93    /// Monotonic per-session index starting at 0. Unique within a
94    /// session; gaps never happen even under load because the sink
95    /// owns the counter under a mutex.
96    pub index: u64,
97    /// Milliseconds since the Unix epoch, captured when the sink
98    /// received the event. Not the event's emission time — that
99    /// would require threading a clock through every emit site.
100    pub emitted_at_ms: i64,
101    /// Call-stack depth at the moment of emission, when the caller
102    /// can supply it. `None` for events emitted from a context where
103    /// the VM frame stack isn't available.
104    pub frame_depth: Option<u32>,
105    /// The raw event, flattened so `jq '.type'` works as expected.
106    #[serde(flatten)]
107    pub event: AgentEvent,
108}
109
110/// Append-only JSONL sink for a single session's event stream (#103).
111/// One writer per session; sinks rotate to a numbered suffix when a
112/// running file crosses `ROTATE_BYTES` (100 MB today — long chat
113/// sessions rarely exceed 5 MB, so rotation almost never fires).
114pub struct JsonlEventSink {
115    state: Mutex<JsonlEventSinkState>,
116    base_path: std::path::PathBuf,
117}
118
119struct JsonlEventSinkState {
120    writer: std::io::BufWriter<std::fs::File>,
121    index: u64,
122    bytes_written: u64,
123    rotation: u32,
124    first_error: Option<AgentEventSinkError>,
125}
126
127impl JsonlEventSink {
128    /// Hard cap past which the current file rotates to a numbered
129    /// suffix (`event_log-000001.jsonl`). Chosen so long debugging
130    /// sessions don't produce unreadable multi-GB logs.
131    pub const ROTATE_BYTES: u64 = 100 * 1024 * 1024;
132
133    /// Open a new sink writing to `base_path`. Creates parent dirs
134    /// if missing. Overwrites an existing file so each fresh session
135    /// starts from index 0.
136    pub fn open(base_path: impl Into<std::path::PathBuf>) -> std::io::Result<Arc<Self>> {
137        let base_path = base_path.into();
138        if let Some(parent) = base_path.parent() {
139            std::fs::create_dir_all(parent)?;
140        }
141        let file = std::fs::OpenOptions::new()
142            .create(true)
143            .truncate(true)
144            .write(true)
145            .open(&base_path)?;
146        Ok(Arc::new(Self {
147            state: Mutex::new(JsonlEventSinkState {
148                writer: std::io::BufWriter::new(file),
149                index: 0,
150                bytes_written: 0,
151                rotation: 0,
152                first_error: None,
153            }),
154            base_path,
155        }))
156    }
157
158    /// Flush any buffered writes. Called on session shutdown; the
159    /// Drop impl calls this too but on early panic it may not run.
160    pub fn flush(&self) -> Result<(), AgentEventSinkError> {
161        use std::io::Write as _;
162        let mut state = self.state.lock().expect("jsonl sink mutex poisoned");
163        if let Err(error) = state.writer.flush() {
164            record_sink_failure(
165                &mut state.first_error,
166                AgentEventSinkError::new("jsonl_event", error),
167            );
168        }
169        state.first_error.clone().map_or(Ok(()), Err)
170    }
171
172    /// Current event index — primarily for tests and the "how many
173    /// events are in this run" run-record summary.
174    pub fn event_count(&self) -> u64 {
175        self.state.lock().expect("jsonl sink mutex poisoned").index
176    }
177
178    fn rotate_if_needed(&self, state: &mut JsonlEventSinkState) -> std::io::Result<()> {
179        use std::io::Write as _;
180        if state.bytes_written < Self::ROTATE_BYTES {
181            return Ok(());
182        }
183        state.writer.flush()?;
184        state.rotation += 1;
185        let suffix = format!("-{:06}", state.rotation);
186        let rotated = self.base_path.with_file_name({
187            let stem = self
188                .base_path
189                .file_stem()
190                .and_then(|s| s.to_str())
191                .unwrap_or("event_log");
192            let ext = self
193                .base_path
194                .extension()
195                .and_then(|e| e.to_str())
196                .unwrap_or("jsonl");
197            format!("{stem}{suffix}.{ext}")
198        });
199        let file = std::fs::OpenOptions::new()
200            .create(true)
201            .truncate(true)
202            .write(true)
203            .open(&rotated)?;
204        state.writer = std::io::BufWriter::new(file);
205        state.bytes_written = 0;
206        Ok(())
207    }
208}
209
210/// Event-log-backed sink for a single session's agent event stream.
211/// Uses the generalized append-only event log when one is installed for
212/// the current VM thread and falls back to `JsonlEventSink` only for
213/// older env-driven workflows.
214pub struct EventLogSink {
215    dispatch: EventLogSinkDispatch,
216    session_id: String,
217    first_error: Arc<Mutex<Option<AgentEventSinkError>>>,
218}
219
220enum EventLogSinkDispatch {
221    Async(tokio::sync::mpsc::UnboundedSender<EventLogSinkCommand>),
222    Blocking { log: Arc<AnyEventLog>, topic: Topic },
223}
224
225enum EventLogSinkCommand {
226    Append(EventLogRecord),
227    Flush(tokio::sync::oneshot::Sender<Result<(), AgentEventSinkError>>),
228}
229
230impl EventLogSink {
231    pub fn new(log: Arc<AnyEventLog>, session_id: impl Into<String>) -> Arc<Self> {
232        let session_id = session_id.into();
233        let topic = Topic::new(format!(
234            "observability.agent_events.{}",
235            crate::event_log::sanitize_topic_component(&session_id)
236        ))
237        .expect("session id should sanitize to a valid topic");
238        let first_error = Arc::new(Mutex::new(None));
239        let dispatch = if let Ok(handle) = tokio::runtime::Handle::try_current() {
240            let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
241            handle.spawn(run_event_log_sink_worker(
242                log,
243                topic,
244                receiver,
245                first_error.clone(),
246            ));
247            EventLogSinkDispatch::Async(sender)
248        } else {
249            EventLogSinkDispatch::Blocking { log, topic }
250        };
251        Arc::new(Self {
252            dispatch,
253            session_id,
254            first_error,
255        })
256    }
257
258    pub async fn flush(&self) -> Result<(), AgentEventSinkError> {
259        match &self.dispatch {
260            EventLogSinkDispatch::Async(sender) => {
261                let (reply, response) = tokio::sync::oneshot::channel();
262                if sender.send(EventLogSinkCommand::Flush(reply)).is_err() {
263                    return Err(self.latched_error().unwrap_or_else(|| {
264                        AgentEventSinkError::new("event_log", "append worker is unavailable")
265                    }));
266                }
267                response.await.unwrap_or_else(|_| {
268                    Err(self.latched_error().unwrap_or_else(|| {
269                        AgentEventSinkError::new("event_log", "append worker stopped before flush")
270                    }))
271                })
272            }
273            EventLogSinkDispatch::Blocking { log, .. } => {
274                let append_error = self.latched_error();
275                let flush_result = flush_event_log(log.clone()).await;
276                append_error.map_or(flush_result, Err)
277            }
278        }
279    }
280
281    fn latched_error(&self) -> Option<AgentEventSinkError> {
282        self.first_error
283            .lock()
284            .expect("event-log sink error mutex poisoned")
285            .clone()
286    }
287
288    #[cfg(test)]
289    pub(crate) fn enqueue_flush_for_test(
290        &self,
291    ) -> tokio::sync::oneshot::Receiver<Result<(), AgentEventSinkError>> {
292        let EventLogSinkDispatch::Async(sender) = &self.dispatch else {
293            panic!("test flush enqueue requires an async event-log sink");
294        };
295        let (reply, response) = tokio::sync::oneshot::channel();
296        sender
297            .send(EventLogSinkCommand::Flush(reply))
298            .expect("event-log sink worker should accept test flush");
299        response
300    }
301}
302
303async fn run_event_log_sink_worker(
304    log: Arc<AnyEventLog>,
305    topic: Topic,
306    mut receiver: tokio::sync::mpsc::UnboundedReceiver<EventLogSinkCommand>,
307    first_error: Arc<Mutex<Option<AgentEventSinkError>>>,
308) {
309    while let Some(command) = receiver.recv().await {
310        match command {
311            EventLogSinkCommand::Append(record) => {
312                if let Err(error) = log.append(&topic, record).await {
313                    record_sink_failure(
314                        &mut first_error
315                            .lock()
316                            .expect("event-log sink error mutex poisoned"),
317                        AgentEventSinkError::dropped_event("event_log", error),
318                    );
319                }
320            }
321            EventLogSinkCommand::Flush(reply) => {
322                let flush_result = flush_event_log(log.clone()).await;
323                let append_error = first_error
324                    .lock()
325                    .expect("event-log sink error mutex poisoned")
326                    .clone();
327                let result = append_error.map_or(flush_result, Err);
328                let _ = reply.send(result);
329            }
330        }
331    }
332}
333
334#[derive(Clone, Copy, Debug, Eq, PartialEq)]
335pub(super) enum EventLogFlushSchedule {
336    AsyncExecutor,
337    BlockingPool,
338}
339
340pub(super) fn event_log_flush_schedule(log: &AnyEventLog) -> EventLogFlushSchedule {
341    if matches!(log, AnyEventLog::Sqlite(_)) {
342        EventLogFlushSchedule::BlockingPool
343    } else {
344        EventLogFlushSchedule::AsyncExecutor
345    }
346}
347
348async fn flush_event_log(log: Arc<AnyEventLog>) -> Result<(), AgentEventSinkError> {
349    let result = if event_log_flush_schedule(&log) == EventLogFlushSchedule::BlockingPool
350        && tokio::runtime::Handle::try_current().is_ok()
351    {
352        tokio::task::spawn_blocking(move || futures::executor::block_on(log.flush()))
353            .await
354            .map_err(|error| {
355                AgentEventSinkError::new("event_log", format!("flush task failed: {error}"))
356            })?
357    } else {
358        log.flush().await
359    };
360    result.map_err(|error| AgentEventSinkError::new("event_log", error))
361}
362
363impl AgentEventSink for JsonlEventSink {
364    fn handle_event(&self, event: &AgentEvent) {
365        use std::io::Write as _;
366        let mut state = self.state.lock().expect("jsonl sink mutex poisoned");
367        if state.first_error.is_some() {
368            let error = AgentEventSinkError::dropped_event(
369                "jsonl_event",
370                "sink unavailable after an earlier persistence failure",
371            );
372            record_sink_failure(&mut state.first_error, error);
373            return;
374        }
375        let index = state.index;
376        let emitted_at_ms = std::time::SystemTime::now()
377            .duration_since(std::time::UNIX_EPOCH)
378            .map(|d| d.as_millis() as i64)
379            .unwrap_or(0);
380        let envelope = PersistedAgentEvent {
381            index,
382            emitted_at_ms,
383            frame_depth: None,
384            event: event.clone(),
385        };
386        let mut envelope_json = match serde_json::to_value(&envelope) {
387            Ok(value) => value,
388            Err(error) => {
389                record_sink_failure(
390                    &mut state.first_error,
391                    AgentEventSinkError::dropped_event("jsonl_event", error),
392                );
393                return;
394            }
395        };
396        crate::redact::current_policy().redact_json_in_place(&mut envelope_json);
397        let mut line = match serde_json::to_vec(&envelope_json) {
398            Ok(line) => line,
399            Err(error) => {
400                record_sink_failure(
401                    &mut state.first_error,
402                    AgentEventSinkError::dropped_event("jsonl_event", error),
403                );
404                return;
405            }
406        };
407        line.push(b'\n');
408        if let Err(error) = state
409            .writer
410            .write_all(&line)
411            .and_then(|_| state.writer.flush())
412        {
413            record_sink_failure(
414                &mut state.first_error,
415                AgentEventSinkError::dropped_event("jsonl_event", error),
416            );
417            return;
418        }
419        state.index += 1;
420        state.bytes_written += line.len() as u64;
421        if let Err(error) = self.rotate_if_needed(&mut state) {
422            record_sink_failure(
423                &mut state.first_error,
424                AgentEventSinkError::new("jsonl_event", error),
425            );
426        }
427    }
428
429    fn flush(&self) -> AgentEventSinkFlush<'_> {
430        Box::pin(async move { JsonlEventSink::flush(self) })
431    }
432}
433
434impl AgentEventSink for EventLogSink {
435    fn handle_event(&self, event: &AgentEvent) {
436        let event_json = match serde_json::to_value(event) {
437            Ok(value) => value,
438            Err(error) => {
439                record_sink_failure(
440                    &mut self
441                        .first_error
442                        .lock()
443                        .expect("event-log sink error mutex poisoned"),
444                    AgentEventSinkError::dropped_event("event_log", error),
445                );
446                return;
447            }
448        };
449        let event_kind = event_json
450            .get("type")
451            .and_then(|value| value.as_str())
452            .unwrap_or("agent_event")
453            .to_string();
454        let payload = serde_json::json!({
455            "index_hint": now_ms(),
456            "session_id": self.session_id,
457            "event": event_json,
458        });
459        let mut headers = std::collections::BTreeMap::new();
460        headers.insert("session_id".to_string(), self.session_id.clone());
461        let mut record = EventLogRecord::new(event_kind, payload).with_headers(headers);
462        record.redact_in_place(&crate::redact::current_policy());
463        match &self.dispatch {
464            EventLogSinkDispatch::Async(sender) => {
465                if sender.send(EventLogSinkCommand::Append(record)).is_err() {
466                    record_sink_failure(
467                        &mut self
468                            .first_error
469                            .lock()
470                            .expect("event-log sink error mutex poisoned"),
471                        AgentEventSinkError::dropped_event(
472                            "event_log",
473                            "append worker is unavailable",
474                        ),
475                    );
476                }
477            }
478            EventLogSinkDispatch::Blocking { log, topic } => {
479                if let Err(error) = futures::executor::block_on(log.append(topic, record)) {
480                    record_sink_failure(
481                        &mut self
482                            .first_error
483                            .lock()
484                            .expect("event-log sink error mutex poisoned"),
485                        AgentEventSinkError::dropped_event("event_log", error),
486                    );
487                }
488            }
489        }
490    }
491
492    fn flush(&self) -> AgentEventSinkFlush<'_> {
493        Box::pin(EventLogSink::flush(self))
494    }
495}
496
497impl Drop for JsonlEventSink {
498    fn drop(&mut self) {
499        if let Ok(mut state) = self.state.lock() {
500            use std::io::Write as _;
501            let _ = state.writer.flush();
502        }
503    }
504}
505
506/// Fan-out helper for composing multiple external sinks.
507pub struct MultiSink {
508    sinks: Mutex<Vec<Arc<dyn AgentEventSink>>>,
509}
510
511impl MultiSink {
512    pub fn new() -> Self {
513        Self {
514            sinks: Mutex::new(Vec::new()),
515        }
516    }
517    pub fn push(&self, sink: Arc<dyn AgentEventSink>) {
518        self.sinks.lock().expect("sink mutex poisoned").push(sink);
519    }
520    pub fn len(&self) -> usize {
521        self.sinks.lock().expect("sink mutex poisoned").len()
522    }
523    pub fn is_empty(&self) -> bool {
524        self.len() == 0
525    }
526}
527
528impl Default for MultiSink {
529    fn default() -> Self {
530        Self::new()
531    }
532}
533
534impl AgentEventSink for MultiSink {
535    fn handle_event(&self, event: &AgentEvent) {
536        // Deliberate: snapshot then release the lock before invoking sink
537        // callbacks. Sinks can re-enter the event system (e.g. a host
538        // sink that logs to another AgentEvent path), so holding the
539        // mutex across the callback would risk self-deadlock. Arc clones
540        // are refcount bumps — cheap.
541        let sinks = self.sinks.lock().expect("sink mutex poisoned").clone();
542        for sink in sinks {
543            sink.handle_event(event);
544        }
545    }
546
547    fn flush(&self) -> AgentEventSinkFlush<'_> {
548        let sinks = self.sinks.lock().expect("sink mutex poisoned").clone();
549        Box::pin(flush_all_sinks(sinks))
550    }
551}
552
553pub(super) async fn flush_all_sinks(
554    sinks: impl IntoIterator<Item = Arc<dyn AgentEventSink>>,
555) -> Result<(), AgentEventSinkError> {
556    let mut first_error = None;
557    for sink in sinks {
558        if let Err(error) = sink.flush().await {
559            first_error.get_or_insert(error);
560        }
561    }
562    first_error.map_or(Ok(()), Err)
563}
564
565pub(super) fn now_ms() -> i64 {
566    std::time::SystemTime::now()
567        .duration_since(std::time::UNIX_EPOCH)
568        .map(|duration| duration.as_millis() as i64)
569        .unwrap_or(0)
570}