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        Event::TerminalFinalState { .. } => "terminal_final_state",
288        Event::MermaidDiagram { .. } => "mermaid_diagram",
289    }
290}
291
292pub(crate) fn extract_anchors(event: &Event) -> (Option<String>, Option<String>) {
293    match event {
294        Event::FlowStart { run_id, .. } | Event::FlowEnd { run_id, .. } => {
295            (None, Some(run_id.0.to_string()))
296        }
297        Event::TurnStart { turn_id, .. } | Event::TurnEnd { turn_id, .. } => {
298            (Some(turn_id.0.to_string()), None)
299        }
300        Event::UserMsg { turn_id, .. }
301        | Event::SystemMsg { turn_id, .. }
302        | Event::UserInject { turn_id, .. } => (Some(turn_id.0.to_string()), None),
303        Event::AssistantMsg {
304            turn_id,
305            flow_run_id,
306            ..
307        }
308        | Event::ToolResultMsg {
309            turn_id,
310            flow_run_id,
311            ..
312        } => (
313            Some(turn_id.0.to_string()),
314            flow_run_id.as_ref().map(|r| r.0.to_string()),
315        ),
316        Event::DiffPreview {
317            turn_id,
318            flow_run_id,
319            ..
320        } => (
321            turn_id.as_ref().map(|t| t.0.to_string()),
322            flow_run_id.as_ref().map(|r| r.0.to_string()),
323        ),
324        Event::ContentFilterHit {
325            turn_id,
326            flow_run_id,
327            ..
328        }
329        | Event::ContextTruncated {
330            turn_id,
331            flow_run_id,
332            ..
333        }
334        | Event::WatchWarn {
335            turn_id,
336            flow_run_id,
337            ..
338        } => (
339            turn_id.as_ref().map(|t| t.0.to_string()),
340            flow_run_id.as_ref().map(|r| r.0.to_string()),
341        ),
342        Event::LlmPartialCall {
343            turn_id,
344            flow_run_id,
345            ..
346        } => (
347            turn_id.as_ref().map(|t| t.0.to_string()),
348            flow_run_id.as_ref().map(|r| r.0.to_string()),
349        ),
350        Event::FlowGraph { run_id, .. }
351        | Event::FlowNodeStart { run_id, .. }
352        | Event::FlowNodeEnd { run_id, .. }
353        | Event::ToolNode { run_id, .. }
354        | Event::ToolPendingApproval { run_id, .. }
355        | Event::ToolApproved { run_id, .. }
356        | Event::ToolDenied { run_id, .. } => (None, Some(run_id.0.to_string())),
357        Event::AttachmentDegraded {
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::CompactionSummary { .. }
366        | Event::LlmCall { .. }
367        | Event::ContextCompact { .. }
368        | Event::Checkpoint { .. }
369        | Event::PendingPrompt { .. }
370        | Event::PromptResolved { .. }
371        | Event::TerminalFinalState { .. }
372        | Event::MermaidDiagram { .. } => (None, None),
373    }
374}
375
376pub(crate) fn extract_text_content(event: &Event) -> Option<String> {
377    match event {
378        Event::UserMsg { message, .. }
379        | Event::AssistantMsg { message, .. }
380        | Event::ToolResultMsg { message, .. }
381        | Event::SystemMsg { message, .. } => Some(message.text_concat()),
382        Event::WatchWarn { message, .. } => Some(message.clone()),
383        Event::CompactionSummary { summary, .. } => Some(summary.clone()),
384        Event::AttachmentDegraded {
385            file_basename,
386            reason,
387            ..
388        } => Some(format!("{file_basename} {reason}")),
389        _ => None,
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::event::{Event, FlowRunId, FlowStatus};
397    use tempfile::TempDir;
398
399    #[tokio::test]
400    async fn writer_appends_events_as_jsonl() {
401        let dir = TempDir::new().unwrap();
402        let writer = EventWriter::spawn(dir.path()).unwrap();
403        let tx = writer.sender();
404        for i in 0..5 {
405            tx.send(EventEnvelope::new(
406                i as u64,
407                Event::FlowStart {
408                    run_id: FlowRunId::now(),
409                    flow_name: format!("flow_{i}"),
410                    parent_run_id: None,
411                    parent_node_id: None,
412                },
413            ))
414            .unwrap();
415        }
416        drop(tx);
417        writer.shutdown().await;
418        let path = dir.path().join("events.jsonl");
419        let contents = tokio::fs::read_to_string(&path).await.unwrap();
420        let lines: Vec<_> = contents.lines().collect();
421        assert_eq!(lines.len(), 5);
422        for line in lines {
423            let v: serde_json::Value = serde_json::from_str(line).unwrap();
424            assert_eq!(v["type"], "flow_start");
425            assert!(v["run_id"].is_string());
426            assert!(v["flow_name"].is_string());
427        }
428    }
429
430    #[tokio::test]
431    async fn writer_writes_to_project_index_with_session_id() {
432        let session_dir = TempDir::new().unwrap();
433        let project_dir = TempDir::new().unwrap();
434        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
435        let writer = EventWriter::spawn_full(
436            session_dir.path(),
437            None,
438            Some(idx.clone()),
439            Some("sess-x".into()),
440        )
441        .unwrap();
442        let tx = writer.sender();
443        for i in 0..3 {
444            tx.send(EventEnvelope::new(
445                (i + 1) as u64,
446                Event::FlowStart {
447                    run_id: FlowRunId::now(),
448                    flow_name: format!("flow_{i}"),
449                    parent_run_id: None,
450                    parent_node_id: None,
451                },
452            ))
453            .unwrap();
454        }
455        drop(tx);
456        writer.shutdown().await;
457
458        let jsonl_lines = tokio::fs::read_to_string(session_dir.path().join("events.jsonl"))
459            .await
460            .unwrap()
461            .lines()
462            .count();
463        assert_eq!(jsonl_lines, 3);
464
465        let conn = idx.conn();
466        let count: i64 = conn
467            .query_row(
468                "SELECT COUNT(*) FROM events WHERE session_id = ?",
469                rusqlite::params!["sess-x"],
470                |r| r.get(0),
471            )
472            .unwrap();
473        assert_eq!(count, 3);
474    }
475
476    #[tokio::test]
477    async fn writer_indexes_user_msg_text_for_project_fts() {
478        use crate::event::TurnId;
479        use crate::message::Message;
480
481        let session_dir = TempDir::new().unwrap();
482        let project_dir = TempDir::new().unwrap();
483        let idx = Arc::new(AnchorIndex::open_project(project_dir.path()).unwrap());
484        let writer = EventWriter::spawn_full(
485            session_dir.path(),
486            None,
487            Some(idx.clone()),
488            Some("sess-x".into()),
489        )
490        .unwrap();
491        let tid = TurnId::now();
492        writer
493            .sender()
494            .send(EventEnvelope::new(
495                1,
496                Event::UserMsg {
497                    turn_id: tid.clone(),
498                    message: Message::user_text(tid, "sqlite fts full text search"),
499                },
500            ))
501            .unwrap();
502        writer.shutdown().await;
503
504        let hits = idx
505            .fts_search_project_events("sqlite", Some("sess-x"), 10)
506            .unwrap();
507        assert_eq!(hits.len(), 1);
508        assert_eq!(hits[0].session_id, "sess-x");
509        assert_eq!(hits[0].seq, 1);
510    }
511
512    #[tokio::test]
513    async fn writer_serializes_flow_end_with_status() {
514        let dir = TempDir::new().unwrap();
515        let writer = EventWriter::spawn(dir.path()).unwrap();
516        writer
517            .sender()
518            .send(EventEnvelope::new(
519                0,
520                Event::FlowEnd {
521                    run_id: FlowRunId::now(),
522                    flow_name: "t".into(),
523                    status: FlowStatus::Errored {
524                        message: "boom".into(),
525                    },
526                },
527            ))
528            .unwrap();
529        writer.shutdown().await;
530        let contents = tokio::fs::read_to_string(dir.path().join("events.jsonl"))
531            .await
532            .unwrap();
533        let v: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
534        assert_eq!(v["type"], "flow_end");
535        assert_eq!(v["status"]["kind"], "errored");
536    }
537}