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 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}