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