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;
8use crate::index::{AnchorIndex, ProjectEventInsert};
9use crate::redact::Redactor;
10
11pub struct EventWriter {
12    thread: Option<std::thread::JoinHandle<()>>,
13    tx: mpsc::UnboundedSender<Event>,
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::<Event>();
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                        eprintln!("[atman] 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                        eprintln!("[atman] 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<Event> {
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<Event>,
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    event: &Event,
181    indexer: Option<&(Arc<AnchorIndex>, String)>,
182    redactor: Option<&Redactor>,
183) -> std::io::Result<()> {
184    let line = serialize_event(event, 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, event, &line)
190    {
191        eprintln!(
192            "[atman] project index insert failed (seq={}): {e}",
193            event.seq()
194        );
195    }
196    Ok(())
197}
198
199fn serialize_event(event: &Event, redactor: Option<&Redactor>) -> String {
200    let Some(r) = redactor else {
201        return serde_json::to_string(event).unwrap_or_else(|e| {
202            format!(
203                "{{\"type\":\"encode_error\",\"error\":{:?}}}",
204                e.to_string()
205            )
206        });
207    };
208    let mut value = match serde_json::to_value(event) {
209        Ok(v) => v,
210        Err(e) => {
211            return format!(
212                "{{\"type\":\"encode_error\",\"error\":{:?}}}",
213                e.to_string()
214            );
215        }
216    };
217    r.redact_json(&mut value);
218    serde_json::to_string(&value).unwrap_or_else(|e| {
219        format!(
220            "{{\"type\":\"encode_error\",\"error\":{:?}}}",
221            e.to_string()
222        )
223    })
224}
225
226fn insert_project_row(
227    index: &AnchorIndex,
228    session_id: &str,
229    event: &Event,
230    payload_json: &str,
231) -> rusqlite::Result<()> {
232    let ts = extract_ts(event);
233    let kind = event_kind(event);
234    let (turn_id, flow_run_id) = extract_anchors(event);
235    let text = extract_text_content(event).unwrap_or_default();
236    index.insert_project_event_raw(ProjectEventInsert {
237        session_id,
238        seq: event.seq() as i64,
239        ts: &ts,
240        kind,
241        turn_id: turn_id.as_deref(),
242        flow_run_id: flow_run_id.as_deref(),
243        text_content: &text,
244        payload_json,
245    })?;
246    Ok(())
247}
248
249pub(crate) fn extract_ts(event: &Event) -> String {
250    match event {
251        Event::FlowStart { ts, .. }
252        | Event::FlowEnd { ts, .. }
253        | Event::LlmCall { ts, .. }
254        | Event::TurnStart { ts, .. }
255        | Event::TurnEnd { ts, .. }
256        | Event::UserMsg { ts, .. }
257        | Event::AssistantMsg { ts, .. }
258        | Event::ToolResultMsg { ts, .. }
259        | Event::DiffPreview { ts, .. }
260        | Event::CompactionSummary { ts, .. }
261        | Event::SystemMsg { ts, .. }
262        | Event::UserInject { ts, .. }
263        | Event::ContentFilterHit { ts, .. }
264        | Event::ContextCompact { ts, .. }
265        | Event::Checkpoint { ts, .. }
266        | Event::ContextTruncated { ts, .. }
267        | Event::WatchWarn { ts, .. }
268        | Event::PendingPrompt { ts, .. }
269        | Event::PromptResolved { ts, .. }
270        | Event::LlmPartialCall { ts, .. }
271        | Event::FlowGraph { ts, .. }
272        | Event::FlowNodeStart { ts, .. }
273        | Event::FlowNodeEnd { ts, .. }
274        | Event::ToolNode { ts, .. }
275        | Event::AttachmentDegraded { ts, .. }
276        | Event::ToolPendingApproval { ts, .. }
277        | Event::ToolApproved { ts, .. }
278        | Event::ToolDenied { ts, .. } => ts.to_rfc3339(),
279    }
280}
281
282pub(crate) fn event_kind(event: &Event) -> &'static str {
283    match event {
284        Event::FlowStart { .. } => "flow_start",
285        Event::FlowEnd { .. } => "flow_end",
286        Event::LlmCall { .. } => "llm_call",
287        Event::TurnStart { .. } => "turn_start",
288        Event::TurnEnd { .. } => "turn_end",
289        Event::UserMsg { .. } => "user_msg",
290        Event::AssistantMsg { .. } => "assistant_msg",
291        Event::ToolResultMsg { .. } => "tool_result_msg",
292        Event::DiffPreview { .. } => "diff_preview",
293        Event::CompactionSummary { .. } => "compaction_summary",
294        Event::SystemMsg { .. } => "system_msg",
295        Event::UserInject { .. } => "user_inject",
296        Event::ContentFilterHit { .. } => "content_filter_hit",
297        Event::ContextCompact { .. } => "context_compact",
298        Event::Checkpoint { .. } => "checkpoint",
299        Event::ContextTruncated { .. } => "context_truncated",
300        Event::WatchWarn { .. } => "watch_warn",
301        Event::PendingPrompt { .. } => "pending_prompt",
302        Event::PromptResolved { .. } => "prompt_resolved",
303        Event::LlmPartialCall { .. } => "llm_partial_call",
304        Event::FlowGraph { .. } => "flow_graph",
305        Event::FlowNodeStart { .. } => "flow_node_start",
306        Event::FlowNodeEnd { .. } => "flow_node_end",
307        Event::ToolNode { .. } => "tool_node",
308        Event::AttachmentDegraded { .. } => "attachment_degraded",
309        Event::ToolPendingApproval { .. } => "tool_pending_approval",
310        Event::ToolApproved { .. } => "tool_approved",
311        Event::ToolDenied { .. } => "tool_denied",
312    }
313}
314
315pub(crate) fn extract_anchors(event: &Event) -> (Option<String>, Option<String>) {
316    match event {
317        Event::FlowStart { run_id, .. } | Event::FlowEnd { run_id, .. } => {
318            (None, Some(run_id.0.to_string()))
319        }
320        Event::TurnStart { turn_id, .. } | Event::TurnEnd { turn_id, .. } => {
321            (Some(turn_id.0.to_string()), None)
322        }
323        Event::UserMsg { turn_id, .. }
324        | Event::SystemMsg { turn_id, .. }
325        | Event::UserInject { turn_id, .. } => (Some(turn_id.0.to_string()), None),
326        Event::AssistantMsg {
327            turn_id,
328            flow_run_id,
329            ..
330        }
331        | Event::ToolResultMsg {
332            turn_id,
333            flow_run_id,
334            ..
335        } => (
336            Some(turn_id.0.to_string()),
337            flow_run_id.as_ref().map(|r| r.0.to_string()),
338        ),
339        Event::DiffPreview {
340            turn_id,
341            flow_run_id,
342            ..
343        } => (
344            turn_id.as_ref().map(|t| t.0.to_string()),
345            flow_run_id.as_ref().map(|r| r.0.to_string()),
346        ),
347        Event::ContentFilterHit {
348            turn_id,
349            flow_run_id,
350            ..
351        }
352        | Event::ContextTruncated {
353            turn_id,
354            flow_run_id,
355            ..
356        }
357        | Event::WatchWarn {
358            turn_id,
359            flow_run_id,
360            ..
361        } => (
362            turn_id.as_ref().map(|t| t.0.to_string()),
363            flow_run_id.as_ref().map(|r| r.0.to_string()),
364        ),
365        Event::LlmPartialCall {
366            turn_id,
367            flow_run_id,
368            ..
369        } => (
370            turn_id.as_ref().map(|t| t.0.to_string()),
371            flow_run_id.as_ref().map(|r| r.0.to_string()),
372        ),
373        Event::FlowGraph { run_id, .. }
374        | Event::FlowNodeStart { run_id, .. }
375        | Event::FlowNodeEnd { run_id, .. }
376        | Event::ToolNode { run_id, .. }
377        | Event::ToolPendingApproval { run_id, .. }
378        | Event::ToolApproved { run_id, .. }
379        | Event::ToolDenied { run_id, .. } => (None, Some(run_id.0.to_string())),
380        Event::AttachmentDegraded {
381            turn_id,
382            flow_run_id,
383            ..
384        } => (
385            turn_id.as_ref().map(|t| t.0.to_string()),
386            flow_run_id.as_ref().map(|r| r.0.to_string()),
387        ),
388        Event::CompactionSummary { .. }
389        | Event::LlmCall { .. }
390        | Event::ContextCompact { .. }
391        | Event::Checkpoint { .. }
392        | Event::PendingPrompt { .. }
393        | Event::PromptResolved { .. } => (None, None),
394    }
395}
396
397pub(crate) fn extract_text_content(event: &Event) -> Option<String> {
398    match event {
399        Event::UserMsg { message, .. }
400        | Event::AssistantMsg { message, .. }
401        | Event::ToolResultMsg { message, .. }
402        | Event::SystemMsg { message, .. } => Some(message.text_concat()),
403        Event::WatchWarn { message, .. } => Some(message.clone()),
404        Event::CompactionSummary { summary, .. } => Some(summary.clone()),
405        Event::AttachmentDegraded {
406            file_basename,
407            reason,
408            ..
409        } => Some(format!("{file_basename} {reason}")),
410        _ => None,
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::event::{Event, FlowRunId, FlowStatus};
418    use tempfile::TempDir;
419
420    #[tokio::test]
421    async fn writer_appends_events_as_jsonl() {
422        let dir = TempDir::new().unwrap();
423        let writer = EventWriter::spawn(dir.path()).unwrap();
424        let tx = writer.sender();
425        for i in 0..5 {
426            tx.send(Event::FlowStart {
427                seq: 0,
428                run_id: FlowRunId::now(),
429                flow_name: format!("flow_{i}"),
430                parent_run_id: None,
431                parent_node_id: None,
432                ts: chrono::Utc::now(),
433            })
434            .unwrap();
435        }
436        drop(tx);
437        writer.shutdown().await;
438        let path = dir.path().join("events.jsonl");
439        let contents = tokio::fs::read_to_string(&path).await.unwrap();
440        let lines: Vec<_> = contents.lines().collect();
441        assert_eq!(lines.len(), 5);
442        for line in lines {
443            let v: serde_json::Value = serde_json::from_str(line).unwrap();
444            assert_eq!(v["type"], "flow_start");
445            assert!(v["run_id"].is_string());
446            assert!(v["flow_name"].is_string());
447        }
448    }
449
450    #[tokio::test]
451    async fn writer_writes_to_project_index_with_session_id() {
452        let session_dir = TempDir::new().unwrap();
453        let project_dir = TempDir::new().unwrap();
454        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
455        let writer = EventWriter::spawn_full(
456            session_dir.path(),
457            None,
458            Some(idx.clone()),
459            Some("sess-x".into()),
460        )
461        .unwrap();
462        let tx = writer.sender();
463        for i in 0..3 {
464            tx.send(Event::FlowStart {
465                seq: (i + 1) as u64,
466                run_id: FlowRunId::now(),
467                flow_name: format!("flow_{i}"),
468                parent_run_id: None,
469                parent_node_id: None,
470                ts: chrono::Utc::now(),
471            })
472            .unwrap();
473        }
474        drop(tx);
475        writer.shutdown().await;
476
477        let jsonl_lines = tokio::fs::read_to_string(session_dir.path().join("events.jsonl"))
478            .await
479            .unwrap()
480            .lines()
481            .count();
482        assert_eq!(jsonl_lines, 3);
483
484        let conn = idx.conn();
485        let count: i64 = conn
486            .query_row(
487                "SELECT COUNT(*) FROM events WHERE session_id = ?",
488                rusqlite::params!["sess-x"],
489                |r| r.get(0),
490            )
491            .unwrap();
492        assert_eq!(count, 3);
493    }
494
495    #[tokio::test]
496    async fn writer_indexes_user_msg_text_for_project_fts() {
497        use crate::event::TurnId;
498        use crate::message::Message;
499
500        let session_dir = TempDir::new().unwrap();
501        let project_dir = TempDir::new().unwrap();
502        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
503        let writer = EventWriter::spawn_full(
504            session_dir.path(),
505            None,
506            Some(idx.clone()),
507            Some("sess-x".into()),
508        )
509        .unwrap();
510        let tid = TurnId::now();
511        writer
512            .sender()
513            .send(Event::UserMsg {
514                seq: 1,
515                turn_id: tid.clone(),
516                message: Message::user_text(tid, "sqlite fts full text search"),
517                ts: chrono::Utc::now(),
518            })
519            .unwrap();
520        writer.shutdown().await;
521
522        let hits = idx
523            .fts_search_project_events("sqlite", Some("sess-x"), 10)
524            .unwrap();
525        assert_eq!(hits.len(), 1);
526        assert_eq!(hits[0].session_id, "sess-x");
527        assert_eq!(hits[0].seq, 1);
528    }
529
530    #[tokio::test]
531    async fn writer_serializes_flow_end_with_status() {
532        let dir = TempDir::new().unwrap();
533        let writer = EventWriter::spawn(dir.path()).unwrap();
534        writer
535            .sender()
536            .send(Event::FlowEnd {
537                seq: 0,
538                run_id: FlowRunId::now(),
539                flow_name: "t".into(),
540                status: FlowStatus::Errored {
541                    message: "boom".into(),
542                },
543                ts: chrono::Utc::now(),
544            })
545            .unwrap();
546        writer.shutdown().await;
547        let contents = tokio::fs::read_to_string(dir.path().join("events.jsonl"))
548            .await
549            .unwrap();
550        let v: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
551        assert_eq!(v["type"], "flow_end");
552        assert_eq!(v["status"]["kind"], "errored");
553    }
554}