Skip to main content

atman_runtime/
event_writer.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use tokio::io::AsyncWriteExt;
5use tokio::sync::{mpsc, oneshot};
6
7use crate::event::{Event, EventEnvelope};
8use crate::index::{AnchorIndex, ProjectEventInsert};
9use crate::redact::Redactor;
10
11pub struct EventWriter {
12    thread: Option<std::thread::JoinHandle<()>>,
13    tx: mpsc::UnboundedSender<EventEnvelope>,
14    flush_tx: mpsc::UnboundedSender<oneshot::Sender<()>>,
15    stop_tx: Option<oneshot::Sender<()>>,
16    events_path: PathBuf,
17}
18
19impl EventWriter {
20    pub fn spawn(session_dir: impl AsRef<Path>) -> std::io::Result<Self> {
21        Self::spawn_full(session_dir, None, None, None)
22    }
23
24    pub fn spawn_with(
25        session_dir: impl AsRef<Path>,
26        redactor: Option<Arc<Redactor>>,
27    ) -> std::io::Result<Self> {
28        Self::spawn_full(session_dir, redactor, None, None)
29    }
30
31    // Owns its own thread + rt so short-lived caller runtimes
32    // (spawn_blocking + throwaway current_thread rt) can't kill the loop.
33    pub fn spawn_full(
34        session_dir: impl AsRef<Path>,
35        redactor: Option<Arc<Redactor>>,
36        project_index: Option<Arc<AnchorIndex>>,
37        session_id: Option<String>,
38    ) -> std::io::Result<Self> {
39        let session_dir = session_dir.as_ref().to_path_buf();
40        let events_path = session_dir.join("events.jsonl");
41        std::fs::create_dir_all(&session_dir)?;
42        let (tx, rx) = mpsc::unbounded_channel::<EventEnvelope>();
43        let (flush_tx, flush_rx) = mpsc::unbounded_channel::<oneshot::Sender<()>>();
44        let (stop_tx, stop_rx) = oneshot::channel::<()>();
45        let file_path = events_path.clone();
46        let thread = std::thread::Builder::new()
47            .name("atman-event-writer".into())
48            .spawn(move || {
49                let rt = match tokio::runtime::Builder::new_current_thread()
50                    .enable_all()
51                    .build()
52                {
53                    Ok(rt) => rt,
54                    Err(e) => {
55                        crate::notify!(error, "event writer rt init failed: {e}");
56                        return;
57                    }
58                };
59                rt.block_on(async move {
60                    if let Err(e) = writer_loop(
61                        rx,
62                        flush_rx,
63                        stop_rx,
64                        &file_path,
65                        project_index,
66                        session_id,
67                        redactor,
68                    )
69                    .await
70                    {
71                        crate::notify!(error, "event writer failed: {e}");
72                    }
73                });
74            })?;
75        Ok(Self {
76            thread: Some(thread),
77            tx,
78            flush_tx,
79            stop_tx: Some(stop_tx),
80            events_path,
81        })
82    }
83
84    pub async fn flush(&self) {
85        let (tx, rx) = oneshot::channel::<()>();
86        if self.flush_tx.send(tx).is_err() {
87            return;
88        }
89        let _ = rx.await;
90    }
91
92    pub fn sender(&self) -> mpsc::UnboundedSender<EventEnvelope> {
93        self.tx.clone()
94    }
95
96    pub fn events_path(&self) -> &Path {
97        &self.events_path
98    }
99
100    pub async fn shutdown(mut self) {
101        if let Some(stop_tx) = self.stop_tx.take() {
102            let _ = stop_tx.send(());
103        }
104        if let Some(thread) = self.thread.take() {
105            let _ = tokio::task::spawn_blocking(move || {
106                let _ = thread.join();
107            })
108            .await;
109        }
110    }
111}
112
113impl Drop for EventWriter {
114    fn drop(&mut self) {
115        if let Some(stop_tx) = self.stop_tx.take() {
116            let _ = stop_tx.send(());
117        }
118        if let Some(thread) = self.thread.take() {
119            let _ = thread.join();
120        }
121    }
122}
123
124async fn writer_loop(
125    mut rx: mpsc::UnboundedReceiver<EventEnvelope>,
126    mut flush_rx: mpsc::UnboundedReceiver<oneshot::Sender<()>>,
127    mut stop_rx: oneshot::Receiver<()>,
128    path: &Path,
129    project_index: Option<Arc<AnchorIndex>>,
130    session_id: Option<String>,
131    redactor: Option<Arc<Redactor>>,
132) -> std::io::Result<()> {
133    let mut file = tokio::fs::OpenOptions::new()
134        .create(true)
135        .append(true)
136        .open(path)
137        .await?;
138    let indexer = project_index.zip(session_id);
139
140    loop {
141        tokio::select! {
142            biased;
143            _ = &mut stop_rx => {
144                while let Ok(event) = rx.try_recv() {
145                    write_event(&mut file, &event, indexer.as_ref(), redactor.as_deref()).await?;
146                }
147                while let Ok(waiter) = flush_rx.try_recv() {
148                    let _ = waiter.send(());
149                }
150                break;
151            }
152            maybe_event = rx.recv() => {
153                match maybe_event {
154                    Some(event) => {
155                        write_event(&mut file, &event, indexer.as_ref(), redactor.as_deref()).await?;
156                    }
157                    None => break,
158                }
159            }
160            maybe_flush = flush_rx.recv() => {
161                match maybe_flush {
162                    Some(waiter) => {
163                        while let Ok(event) = rx.try_recv() {
164                            write_event(&mut file, &event, indexer.as_ref(), redactor.as_deref()).await?;
165                        }
166                        file.sync_data().await?;
167                        let _ = waiter.send(());
168                    }
169                    None => break,
170                }
171            }
172        }
173    }
174    file.sync_data().await?;
175    Ok(())
176}
177
178async fn write_event(
179    file: &mut tokio::fs::File,
180    envelope: &EventEnvelope,
181    indexer: Option<&(Arc<AnchorIndex>, String)>,
182    redactor: Option<&Redactor>,
183) -> std::io::Result<()> {
184    let line = serialize_event(envelope, redactor);
185    file.write_all(line.as_bytes()).await?;
186    file.write_all(b"\n").await?;
187    file.sync_data().await?;
188    if let Some((idx, sid)) = indexer
189        && let Err(e) = insert_project_row(idx, sid, envelope, &line)
190    {
191        crate::notify!(
192            warn,
193            location = Log,
194            stack = merge_count("project_index.insert_failed", 60_000),
195            "project index insert failed (seq={}): {e}",
196            envelope.seq
197        );
198    }
199    Ok(())
200}
201
202fn serialize_event(envelope: &EventEnvelope, redactor: Option<&Redactor>) -> String {
203    let Some(r) = redactor else {
204        return serde_json::to_string(envelope).unwrap_or_else(|e| {
205            format!(
206                "{{\"type\":\"encode_error\",\"error\":{:?}}}",
207                e.to_string()
208            )
209        });
210    };
211    let mut value = match serde_json::to_value(envelope) {
212        Ok(v) => v,
213        Err(e) => {
214            return format!(
215                "{{\"type\":\"encode_error\",\"error\":{:?}}}",
216                e.to_string()
217            );
218        }
219    };
220    r.redact_json(&mut value);
221    serde_json::to_string(&value).unwrap_or_else(|e| {
222        format!(
223            "{{\"type\":\"encode_error\",\"error\":{:?}}}",
224            e.to_string()
225        )
226    })
227}
228
229fn insert_project_row(
230    index: &AnchorIndex,
231    session_id: &str,
232    envelope: &EventEnvelope,
233    payload_json: &str,
234) -> rusqlite::Result<()> {
235    let event = &envelope.event;
236    let ts = extract_ts(envelope);
237    let kind = event_kind(event);
238    let (turn_id, flow_run_id) = extract_anchors(event);
239    let text = extract_text_content(event).unwrap_or_default();
240    index.insert_project_event_raw(ProjectEventInsert {
241        session_id,
242        seq: envelope.seq as i64,
243        ts: &ts,
244        kind,
245        turn_id: turn_id.as_deref(),
246        flow_run_id: flow_run_id.as_deref(),
247        text_content: &text,
248        payload_json,
249    })?;
250    Ok(())
251}
252
253pub(crate) fn extract_ts(envelope: &EventEnvelope) -> String {
254    envelope.ts.to_rfc3339()
255}
256
257pub(crate) fn event_kind(event: &Event) -> &'static str {
258    match event {
259        Event::FlowStart { .. } => "flow_start",
260        Event::FlowEnd { .. } => "flow_end",
261        Event::LlmCall { .. } => "llm_call",
262        Event::TurnStart { .. } => "turn_start",
263        Event::TurnEnd { .. } => "turn_end",
264        Event::UserMsg { .. } => "user_msg",
265        Event::AssistantMsg { .. } => "assistant_msg",
266        Event::ToolResultMsg { .. } => "tool_result_msg",
267        Event::DiffPreview { .. } => "diff_preview",
268        Event::CompactionSummary { .. } => "compaction_summary",
269        Event::SystemMsg { .. } => "system_msg",
270        Event::UserInject { .. } => "user_inject",
271        Event::ContentFilterHit { .. } => "content_filter_hit",
272        Event::ContextCompact { .. } => "context_compact",
273        Event::Checkpoint { .. } => "checkpoint",
274        Event::ContextTruncated { .. } => "context_truncated",
275        Event::WatchWarn { .. } => "watch_warn",
276        Event::PendingPrompt { .. } => "pending_prompt",
277        Event::PromptResolved { .. } => "prompt_resolved",
278        Event::LlmPartialCall { .. } => "llm_partial_call",
279        Event::FlowGraph { .. } => "flow_graph",
280        Event::FlowNodeStart { .. } => "flow_node_start",
281        Event::FlowNodeEnd { .. } => "flow_node_end",
282        Event::ToolNode { .. } => "tool_node",
283        Event::AttachmentDegraded { .. } => "attachment_degraded",
284        Event::ToolPendingApproval { .. } => "tool_pending_approval",
285        Event::ToolApproved { .. } => "tool_approved",
286        Event::ToolDenied { .. } => "tool_denied",
287    }
288}
289
290pub(crate) fn extract_anchors(event: &Event) -> (Option<String>, Option<String>) {
291    match event {
292        Event::FlowStart { run_id, .. } | Event::FlowEnd { run_id, .. } => {
293            (None, Some(run_id.0.to_string()))
294        }
295        Event::TurnStart { turn_id, .. } | Event::TurnEnd { turn_id, .. } => {
296            (Some(turn_id.0.to_string()), None)
297        }
298        Event::UserMsg { turn_id, .. }
299        | Event::SystemMsg { turn_id, .. }
300        | Event::UserInject { turn_id, .. } => (Some(turn_id.0.to_string()), None),
301        Event::AssistantMsg {
302            turn_id,
303            flow_run_id,
304            ..
305        }
306        | Event::ToolResultMsg {
307            turn_id,
308            flow_run_id,
309            ..
310        } => (
311            Some(turn_id.0.to_string()),
312            flow_run_id.as_ref().map(|r| r.0.to_string()),
313        ),
314        Event::DiffPreview {
315            turn_id,
316            flow_run_id,
317            ..
318        } => (
319            turn_id.as_ref().map(|t| t.0.to_string()),
320            flow_run_id.as_ref().map(|r| r.0.to_string()),
321        ),
322        Event::ContentFilterHit {
323            turn_id,
324            flow_run_id,
325            ..
326        }
327        | Event::ContextTruncated {
328            turn_id,
329            flow_run_id,
330            ..
331        }
332        | Event::WatchWarn {
333            turn_id,
334            flow_run_id,
335            ..
336        } => (
337            turn_id.as_ref().map(|t| t.0.to_string()),
338            flow_run_id.as_ref().map(|r| r.0.to_string()),
339        ),
340        Event::LlmPartialCall {
341            turn_id,
342            flow_run_id,
343            ..
344        } => (
345            turn_id.as_ref().map(|t| t.0.to_string()),
346            flow_run_id.as_ref().map(|r| r.0.to_string()),
347        ),
348        Event::FlowGraph { run_id, .. }
349        | Event::FlowNodeStart { run_id, .. }
350        | Event::FlowNodeEnd { run_id, .. }
351        | Event::ToolNode { run_id, .. }
352        | Event::ToolPendingApproval { run_id, .. }
353        | Event::ToolApproved { run_id, .. }
354        | Event::ToolDenied { run_id, .. } => (None, Some(run_id.0.to_string())),
355        Event::AttachmentDegraded {
356            turn_id,
357            flow_run_id,
358            ..
359        } => (
360            turn_id.as_ref().map(|t| t.0.to_string()),
361            flow_run_id.as_ref().map(|r| r.0.to_string()),
362        ),
363        Event::CompactionSummary { .. }
364        | Event::LlmCall { .. }
365        | Event::ContextCompact { .. }
366        | Event::Checkpoint { .. }
367        | Event::PendingPrompt { .. }
368        | Event::PromptResolved { .. } => (None, None),
369    }
370}
371
372pub(crate) fn extract_text_content(event: &Event) -> Option<String> {
373    match event {
374        Event::UserMsg { message, .. }
375        | Event::AssistantMsg { message, .. }
376        | Event::ToolResultMsg { message, .. }
377        | Event::SystemMsg { message, .. } => Some(message.text_concat()),
378        Event::WatchWarn { message, .. } => Some(message.clone()),
379        Event::CompactionSummary { summary, .. } => Some(summary.clone()),
380        Event::AttachmentDegraded {
381            file_basename,
382            reason,
383            ..
384        } => Some(format!("{file_basename} {reason}")),
385        _ => None,
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::event::{Event, FlowRunId, FlowStatus};
393    use tempfile::TempDir;
394
395    #[tokio::test]
396    async fn writer_appends_events_as_jsonl() {
397        let dir = TempDir::new().unwrap();
398        let writer = EventWriter::spawn(dir.path()).unwrap();
399        let tx = writer.sender();
400        for i in 0..5 {
401            tx.send(EventEnvelope::new(
402                i as u64,
403                Event::FlowStart {
404                    run_id: FlowRunId::now(),
405                    flow_name: format!("flow_{i}"),
406                    parent_run_id: None,
407                    parent_node_id: None,
408                },
409            ))
410            .unwrap();
411        }
412        drop(tx);
413        writer.shutdown().await;
414        let path = dir.path().join("events.jsonl");
415        let contents = tokio::fs::read_to_string(&path).await.unwrap();
416        let lines: Vec<_> = contents.lines().collect();
417        assert_eq!(lines.len(), 5);
418        for line in lines {
419            let v: serde_json::Value = serde_json::from_str(line).unwrap();
420            assert_eq!(v["type"], "flow_start");
421            assert!(v["run_id"].is_string());
422            assert!(v["flow_name"].is_string());
423        }
424    }
425
426    #[tokio::test]
427    async fn writer_writes_to_project_index_with_session_id() {
428        let session_dir = TempDir::new().unwrap();
429        let project_dir = TempDir::new().unwrap();
430        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
431        let writer = EventWriter::spawn_full(
432            session_dir.path(),
433            None,
434            Some(idx.clone()),
435            Some("sess-x".into()),
436        )
437        .unwrap();
438        let tx = writer.sender();
439        for i in 0..3 {
440            tx.send(EventEnvelope::new(
441                (i + 1) as u64,
442                Event::FlowStart {
443                    run_id: FlowRunId::now(),
444                    flow_name: format!("flow_{i}"),
445                    parent_run_id: None,
446                    parent_node_id: None,
447                },
448            ))
449            .unwrap();
450        }
451        drop(tx);
452        writer.shutdown().await;
453
454        let jsonl_lines = tokio::fs::read_to_string(session_dir.path().join("events.jsonl"))
455            .await
456            .unwrap()
457            .lines()
458            .count();
459        assert_eq!(jsonl_lines, 3);
460
461        let conn = idx.conn();
462        let count: i64 = conn
463            .query_row(
464                "SELECT COUNT(*) FROM events WHERE session_id = ?",
465                rusqlite::params!["sess-x"],
466                |r| r.get(0),
467            )
468            .unwrap();
469        assert_eq!(count, 3);
470    }
471
472    #[tokio::test]
473    async fn writer_indexes_user_msg_text_for_project_fts() {
474        use crate::event::TurnId;
475        use crate::message::Message;
476
477        let session_dir = TempDir::new().unwrap();
478        let project_dir = TempDir::new().unwrap();
479        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
480        let writer = EventWriter::spawn_full(
481            session_dir.path(),
482            None,
483            Some(idx.clone()),
484            Some("sess-x".into()),
485        )
486        .unwrap();
487        let tid = TurnId::now();
488        writer
489            .sender()
490            .send(EventEnvelope::new(
491                1,
492                Event::UserMsg {
493                    turn_id: tid.clone(),
494                    message: Message::user_text(tid, "sqlite fts full text search"),
495                },
496            ))
497            .unwrap();
498        writer.shutdown().await;
499
500        let hits = idx
501            .fts_search_project_events("sqlite", Some("sess-x"), 10)
502            .unwrap();
503        assert_eq!(hits.len(), 1);
504        assert_eq!(hits[0].session_id, "sess-x");
505        assert_eq!(hits[0].seq, 1);
506    }
507
508    #[tokio::test]
509    async fn writer_serializes_flow_end_with_status() {
510        let dir = TempDir::new().unwrap();
511        let writer = EventWriter::spawn(dir.path()).unwrap();
512        writer
513            .sender()
514            .send(EventEnvelope::new(
515                0,
516                Event::FlowEnd {
517                    run_id: FlowRunId::now(),
518                    flow_name: "t".into(),
519                    status: FlowStatus::Errored {
520                        message: "boom".into(),
521                    },
522                },
523            ))
524            .unwrap();
525        writer.shutdown().await;
526        let contents = tokio::fs::read_to_string(dir.path().join("events.jsonl"))
527            .await
528            .unwrap();
529        let v: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
530        assert_eq!(v["type"], "flow_end");
531        assert_eq!(v["status"]["kind"], "errored");
532    }
533}