Skip to main content

objectiveai_cli/db/logs/
tee.rs

1//! The log writer's live-conversation tee.
2//!
3//! When [`super::writer`] is about to write a row (its shadow said
4//! `Insert`/`Update`), it also hands an OWNED copy of that row to the
5//! [`ConversationTee`] — a keyed FULL-VALUE frame, exactly what the DB
6//! is being told, shipped to the resident daemon's fixed-name
7//! `conversation.sock` for fan-out to `/agents/instances/{*aih}`
8//! subscribers. The tee is strictly best-effort and NEVER blocks the
9//! writer: `send` is an unbounded-mpsc push, and all socket I/O
10//! (connect, retries, backpressure, daemon-absent) lives on a
11//! detached RX task that drops frames when the daemon is unreachable.
12//!
13//! Frames are teed BEFORE the row's SQL executes, so live delivery is
14//! not gated on DB latency; a row whose INSERT later fails was still
15//! shipped (accepted divergence — reconnecting clients replay DB
16//! truth). One tee (one socket connection) carries rows for MANY
17//! AIHs — a function execution's writer streams every nested agent's
18//! rows — so each frame is tagged with its own row's AIH and the
19//! daemon routes per-frame, never per-connection.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::time::{Duration, Instant};
24
25#[cfg(unix)]
26use interprocess::local_socket::GenericFilePath;
27#[cfg(windows)]
28use interprocess::local_socket::GenericNamespaced;
29use interprocess::local_socket::Name;
30use interprocess::local_socket::tokio::prelude::*;
31use objectiveai_sdk::cli::websocket_agents_instances_listener::{
32    AgentInstanceEvent, AssistantResponsePart, PartContent, RequestMessageUserPart,
33    ToolResponsePart, VectorRequestChoicePart,
34};
35use tokio::io::AsyncWriteExt;
36use tokio::sync::mpsc;
37
38use super::row::RowValue;
39
40/// One JSONL line on `conversation.sock`. CLI-internal (the daemon is
41/// the only reader); the resolved wire type the daemon fans out is the
42/// SDK's `AgentInstanceEvent`.
43#[derive(Debug, serde::Serialize, serde::Deserialize)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum TeeFrame {
46    /// A fully-resolved conversation event, fanned out verbatim.
47    Event { event: AgentInstanceEvent },
48    /// A consumed message-queue notification. The writer only knows
49    /// the `message_queue_contents.id` — the content and the parent
50    /// queue row's metadata live in the DB, so the DAEMON resolves
51    /// them into the typed `ClientNotificationPart` event before
52    /// fan-out (notifications are low-frequency).
53    MessageQueueContent {
54        agent_instance_hierarchy: String,
55        response_id: String,
56        message_queue_content_id: i64,
57        /// RFC3339 — when the writer shipped this frame.
58        delivered_at: String,
59    },
60}
61
62/// Build the tee frame for one logged error — persist-before-return:
63/// the spawn path calls this AFTER `insert_error` committed the row.
64pub fn error_frame(
65    agent_instance_hierarchy: String,
66    response_id: Option<String>,
67    error: serde_json::Value,
68    created_at: i64,
69) -> TeeFrame {
70    TeeFrame::Event {
71        event: AgentInstanceEvent::Error {
72            agent_instance_hierarchy,
73            response_id,
74            error,
75            delivered_at: crate::db::time::unix_to_rfc3339(created_at),
76        },
77    }
78}
79
80/// The writer's row→event mapper. STATEFUL: head rows (the
81/// `tool_response` / `request_message_tool` / `request_vector_choice`
82/// metadata carriers) produce NO frame — they feed the maps below, and
83/// their payload rides every subsequent content event of their block
84/// (`tool_call_id` on the event's boundary fields; the choice's voting
85/// `key` per part). The writer emits each head strictly before its
86/// contents, so a lookup miss means a torn iterator — that content
87/// frame is skipped (reconnecting clients replay DB truth).
88#[derive(Default)]
89pub struct FrameMapper {
90    /// `(aih, response_id, message index)` → `tool_call_id`, from
91    /// `tool_response` heads. The AIH is part of the key because ONE
92    /// writer streams MANY agents' rows (a function execution's
93    /// writer carries every nested agent).
94    tool_response_heads: HashMap<(String, String, i64), String>,
95    /// Same, from `request_message_tool` heads.
96    request_tool_heads: HashMap<(String, String, i64), String>,
97    /// `(aih, response_id, choice index)` → the agent's voting key,
98    /// from `request_vector_choice` heads.
99    choice_keys: HashMap<(String, String, i64), String>,
100}
101
102impl FrameMapper {
103    /// Map one row the writer is about to write into its typed event
104    /// frame. `created_at` is the same timestamp the SQL will bind.
105    /// `None` for head rows (memory only) and for content rows whose
106    /// head never registered.
107    pub fn map(&mut self, value: &RowValue<'_>, created_at: i64) -> Option<TeeFrame> {
108        let delivered_at = crate::db::time::unix_to_rfc3339(created_at);
109        let aih = value.agent_instance_hierarchy().to_string();
110        let event = match value {
111            // ---- daemon-resolved notification rows ----
112            RowValue::MessageQueueContent {
113                response_id,
114                agent_instance_hierarchy,
115                message_queue_content_id,
116            } => {
117                return Some(TeeFrame::MessageQueueContent {
118                    agent_instance_hierarchy: (*agent_instance_hierarchy).to_string(),
119                    response_id: (*response_id).to_string(),
120                    message_queue_content_id: *message_queue_content_id,
121                    delivered_at,
122                });
123            }
124
125            // ---- head rows: memory only, no frame ----
126            RowValue::ToolResponse { tool_call_id, .. } => {
127                self.tool_response_heads.insert(
128                    (aih, value.response_id().to_string(), value.row_index()),
129                    (*tool_call_id).to_string(),
130                );
131                return None;
132            }
133            RowValue::RequestMessageTool { tool_call_id, .. } => {
134                self.request_tool_heads.insert(
135                    (aih, value.response_id().to_string(), value.row_index()),
136                    (*tool_call_id).to_string(),
137                );
138                return None;
139            }
140            RowValue::RequestVectorChoice { key, .. } => {
141                self.choice_keys.insert(
142                    (aih, value.response_id().to_string(), value.row_index()),
143                    (*key).to_string(),
144                );
145                return None;
146            }
147
148            // ---- assistant response ----
149            RowValue::AssistantResponseRefusal { text, .. } => assistant_event(
150                value, false,
151                AssistantResponsePart::Refusal { delivered_at, text: (*text).to_string() },
152            ),
153            RowValue::AssistantResponseReasoning { text, .. } => assistant_event(
154                value, false,
155                AssistantResponsePart::Reasoning { delivered_at, text: (*text).to_string() },
156            ),
157            RowValue::AssistantResponseToolCalls {
158                tool_call_id, function_name, arguments, ..
159            } => assistant_event(
160                value, false,
161                AssistantResponsePart::ToolCall {
162                    delivered_at,
163                    function_name: (*function_name).to_string(),
164                    tool_call_id: (*tool_call_id).to_string(),
165                    tool_call_index: value.row_sub_index().unwrap_or(0),
166                    arguments: (*arguments).to_string(),
167                },
168            ),
169            RowValue::AssistantResponseContentText { text, .. } => assistant_event(
170                value, false,
171                AssistantResponsePart::Text { delivered_at, text: (*text).to_string() },
172            ),
173            RowValue::AssistantResponseContentImage { image_url, .. } => assistant_event(
174                value, false,
175                AssistantResponsePart::Image { delivered_at, image: (*image_url).clone() },
176            ),
177            RowValue::AssistantResponseContentAudio { input_audio, .. } => assistant_event(
178                value, false,
179                AssistantResponsePart::Audio { delivered_at, audio: (*input_audio).clone() },
180            ),
181            RowValue::AssistantResponseContentVideo { video_url, .. } => assistant_event(
182                value, false,
183                AssistantResponsePart::Video { delivered_at, video: (*video_url).clone() },
184            ),
185            RowValue::AssistantResponseContentFile { file, .. } => assistant_event(
186                value, false,
187                AssistantResponsePart::File { delivered_at, file: (*file).clone() },
188            ),
189
190            // ---- request message: assistant (same part shape) ----
191            RowValue::RequestMessageAssistantRefusal { text, .. } => assistant_event(
192                value, true,
193                AssistantResponsePart::Refusal { delivered_at, text: (*text).to_string() },
194            ),
195            RowValue::RequestMessageAssistantReasoning { text, .. } => assistant_event(
196                value, true,
197                AssistantResponsePart::Reasoning { delivered_at, text: (*text).to_string() },
198            ),
199            RowValue::RequestMessageAssistantToolCalls {
200                tool_call_id, function_name, arguments, ..
201            } => assistant_event(
202                value, true,
203                AssistantResponsePart::ToolCall {
204                    delivered_at,
205                    function_name: (*function_name).to_string(),
206                    tool_call_id: (*tool_call_id).to_string(),
207                    tool_call_index: value.row_sub_index().unwrap_or(0),
208                    arguments: (*arguments).to_string(),
209                },
210            ),
211            RowValue::RequestMessageAssistantContentText { text, .. } => assistant_event(
212                value, true,
213                AssistantResponsePart::Text { delivered_at, text: (*text).to_string() },
214            ),
215            RowValue::RequestMessageAssistantContentImage { image_url, .. } => assistant_event(
216                value, true,
217                AssistantResponsePart::Image { delivered_at, image: (*image_url).clone() },
218            ),
219            RowValue::RequestMessageAssistantContentAudio { input_audio, .. } => assistant_event(
220                value, true,
221                AssistantResponsePart::Audio { delivered_at, audio: (*input_audio).clone() },
222            ),
223            RowValue::RequestMessageAssistantContentVideo { video_url, .. } => assistant_event(
224                value, true,
225                AssistantResponsePart::Video { delivered_at, video: (*video_url).clone() },
226            ),
227            RowValue::RequestMessageAssistantContentFile { file, .. } => assistant_event(
228                value, true,
229                AssistantResponsePart::File { delivered_at, file: (*file).clone() },
230            ),
231
232            // ---- tool response content (needs its head's id) ----
233            RowValue::ToolResponseContentText { text, .. } => self.tool_event(
234                value, delivered_at, PartContent::Text { text: (*text).to_string() }, false,
235            )?,
236            RowValue::ToolResponseContentImage { image_url, .. } => self.tool_event(
237                value, delivered_at, PartContent::Image((*image_url).clone()), false,
238            )?,
239            RowValue::ToolResponseContentAudio { input_audio, .. } => self.tool_event(
240                value, delivered_at, PartContent::Audio((*input_audio).clone()), false,
241            )?,
242            RowValue::ToolResponseContentVideo { video_url, .. } => self.tool_event(
243                value, delivered_at, PartContent::Video((*video_url).clone()), false,
244            )?,
245            RowValue::ToolResponseContentFile { file, .. } => self.tool_event(
246                value, delivered_at, PartContent::File((*file).clone()), false,
247            )?,
248            RowValue::RequestMessageToolContentText { text, .. } => self.tool_event(
249                value, delivered_at, PartContent::Text { text: (*text).to_string() }, true,
250            )?,
251            RowValue::RequestMessageToolContentImage { image_url, .. } => self.tool_event(
252                value, delivered_at, PartContent::Image((*image_url).clone()), true,
253            )?,
254            RowValue::RequestMessageToolContentAudio { input_audio, .. } => self.tool_event(
255                value, delivered_at, PartContent::Audio((*input_audio).clone()), true,
256            )?,
257            RowValue::RequestMessageToolContentVideo { video_url, .. } => self.tool_event(
258                value, delivered_at, PartContent::Video((*video_url).clone()), true,
259            )?,
260            RowValue::RequestMessageToolContentFile { file, .. } => self.tool_event(
261                value, delivered_at, PartContent::File((*file).clone()), true,
262            )?,
263
264            // ---- request message: user ----
265            RowValue::RequestMessageUserContentText { text, .. } => user_event(
266                value,
267                RequestMessageUserPart {
268                    delivered_at,
269                    content: PartContent::Text { text: (*text).to_string() },
270                },
271            ),
272            RowValue::RequestMessageUserContentImage { image_url, .. } => user_event(
273                value,
274                RequestMessageUserPart {
275                    delivered_at,
276                    content: PartContent::Image((*image_url).clone()),
277                },
278            ),
279            RowValue::RequestMessageUserContentAudio { input_audio, .. } => user_event(
280                value,
281                RequestMessageUserPart {
282                    delivered_at,
283                    content: PartContent::Audio((*input_audio).clone()),
284                },
285            ),
286            RowValue::RequestMessageUserContentVideo { video_url, .. } => user_event(
287                value,
288                RequestMessageUserPart {
289                    delivered_at,
290                    content: PartContent::Video((*video_url).clone()),
291                },
292            ),
293            RowValue::RequestMessageUserContentFile { file, .. } => user_event(
294                value,
295                RequestMessageUserPart {
296                    delivered_at,
297                    content: PartContent::File((*file).clone()),
298                },
299            ),
300
301            // ---- vector request choice content (needs its head's key) ----
302            RowValue::RequestVectorChoiceContentText { text, .. } => self.choice_event(
303                value, delivered_at, PartContent::Text { text: (*text).to_string() },
304            )?,
305            RowValue::RequestVectorChoiceContentImage { image_url, .. } => self.choice_event(
306                value, delivered_at, PartContent::Image((*image_url).clone()),
307            )?,
308            RowValue::RequestVectorChoiceContentAudio { input_audio, .. } => self.choice_event(
309                value, delivered_at, PartContent::Audio((*input_audio).clone()),
310            )?,
311            RowValue::RequestVectorChoiceContentVideo { video_url, .. } => self.choice_event(
312                value, delivered_at, PartContent::Video((*video_url).clone()),
313            )?,
314            RowValue::RequestVectorChoiceContentFile { file, .. } => self.choice_event(
315                value, delivered_at, PartContent::File((*file).clone()),
316            )?,
317
318            // ---- vector response vote (complete single-row block) ----
319            RowValue::ResponseVectorVote { vote, .. } => {
320                AgentInstanceEvent::VectorResponseVote {
321                    agent_instance_hierarchy: aih,
322                    response_id: value.response_id().to_string(),
323                    vote: vote.to_vec(),
324                }
325            }
326        };
327        Some(TeeFrame::Event { event })
328    }
329
330    /// A tool-response / request-tool content event — `tool_call_id`
331    /// from the block's registered head.
332    fn tool_event(
333        &self,
334        value: &RowValue<'_>,
335        delivered_at: String,
336        content: PartContent,
337        request: bool,
338    ) -> Option<AgentInstanceEvent> {
339        let response_id = value.response_id().to_string();
340        let row_index = value.row_index();
341        let heads = if request {
342            &self.request_tool_heads
343        } else {
344            &self.tool_response_heads
345        };
346        let aih = value.agent_instance_hierarchy().to_string();
347        let tool_call_id = heads.get(&(aih, response_id.clone(), row_index))?.clone();
348        let part = ToolResponsePart {
349            delivered_at,
350            content,
351        };
352        Some(if request {
353            AgentInstanceEvent::RequestMessageToolPart {
354                agent_instance_hierarchy: value.agent_instance_hierarchy().to_string(),
355                response_id,
356                tool_call_id,
357                row_index,
358                row_sub_index: value.row_sub_index(),
359                part,
360            }
361        } else {
362            AgentInstanceEvent::ToolResponsePart {
363                agent_instance_hierarchy: value.agent_instance_hierarchy().to_string(),
364                response_id,
365                tool_call_id,
366                row_index,
367                row_sub_index: value.row_sub_index(),
368                part,
369            }
370        })
371    }
372
373    /// A vector-choice content event — the voting `key` from the
374    /// choice's registered head.
375    fn choice_event(
376        &self,
377        value: &RowValue<'_>,
378        delivered_at: String,
379        content: PartContent,
380    ) -> Option<AgentInstanceEvent> {
381        let response_id = value.response_id().to_string();
382        let choice_index = value.row_index();
383        let key = self
384            .choice_keys
385            .get(&(
386                value.agent_instance_hierarchy().to_string(),
387                response_id.clone(),
388                choice_index,
389            ))?
390            .clone();
391        Some(AgentInstanceEvent::VectorRequestChoicePart {
392            agent_instance_hierarchy: value.agent_instance_hierarchy().to_string(),
393            response_id,
394            key,
395            choice_index,
396            part_index: value.row_sub_index().unwrap_or(0),
397            part: VectorRequestChoicePart {
398                delivered_at,
399                content,
400            },
401        })
402    }
403}
404
405/// An assistant-part event, response (`request == false`) or request
406/// (`request == true`) side.
407fn assistant_event(
408    value: &RowValue<'_>,
409    request: bool,
410    part: AssistantResponsePart,
411) -> AgentInstanceEvent {
412    let agent_instance_hierarchy = value.agent_instance_hierarchy().to_string();
413    let response_id = value.response_id().to_string();
414    let row_index = value.row_index();
415    let row_sub_index = value.row_sub_index();
416    if request {
417        AgentInstanceEvent::RequestMessageAssistantPart {
418            agent_instance_hierarchy,
419            response_id,
420            row_index,
421            row_sub_index,
422            part,
423        }
424    } else {
425        AgentInstanceEvent::AssistantResponsePart {
426            agent_instance_hierarchy,
427            response_id,
428            row_index,
429            row_sub_index,
430            part,
431        }
432    }
433}
434
435/// A user-part event.
436fn user_event(value: &RowValue<'_>, part: RequestMessageUserPart) -> AgentInstanceEvent {
437    AgentInstanceEvent::RequestMessageUserPart {
438        agent_instance_hierarchy: value.agent_instance_hierarchy().to_string(),
439        response_id: value.response_id().to_string(),
440        row_index: value.row_index(),
441        row_sub_index: value.row_sub_index(),
442        part,
443    }
444}
445
446/// The fixed local-socket name for the daemon's conversation hub —
447/// MUST match the listener side in
448/// `crate::websockets::websocket_agent_instance::socket_name`.
449/// Mirrors the `daemon.sock` / `agents.sock` scheme with the constant
450/// `conversation`.
451#[cfg(unix)]
452fn socket_name(state_dir: &Path) -> std::io::Result<Name<'static>> {
453    crate::websockets::mcp_listener::socks_dir(state_dir)
454        .join("conversation.sock")
455        .to_fs_name::<GenericFilePath>()
456}
457
458#[cfg(windows)]
459fn socket_name(state_dir: &Path) -> std::io::Result<Name<'static>> {
460    use std::hash::{Hash, Hasher};
461    // Named pipes are machine-global; fold the state NAME into the
462    // pipe name to preserve per-state isolation (matching
463    // `daemon_stream` / `websocket_agents` / `mcp_listener`).
464    let mut hasher = std::collections::hash_map::DefaultHasher::new();
465    state_dir.file_name().hash(&mut hasher);
466    let state = hasher.finish();
467    format!("objectiveai-{state:016x}-conversation.sock").to_ns_name::<GenericNamespaced>()
468}
469
470/// Handle held by the log writer. Cloneable — one spawn's restart
471/// passes share one tee (one socket connection). Dropping every clone
472/// closes the channel; the RX task drains and exits.
473#[derive(Clone)]
474pub struct ConversationTee {
475    tx: mpsc::UnboundedSender<TeeFrame>,
476}
477
478impl ConversationTee {
479    /// Create the tee and detach its RX task. The task owns the
480    /// receiver and the socket connection; all I/O failure modes
481    /// (daemon absent, mid-stream write error) degrade to dropped
482    /// frames, never to writer backpressure.
483    pub fn spawn(state_dir: PathBuf) -> Self {
484        let (tx, rx) = mpsc::unbounded_channel();
485        tokio::spawn(rx_task(state_dir, rx));
486        Self { tx }
487    }
488
489    /// Fire-and-forget. A dead RX task (should not happen while any
490    /// clone lives) means the frame is silently dropped.
491    pub fn send(&self, frame: TeeFrame) {
492        let _ = self.tx.send(frame);
493    }
494}
495
496/// Drain the channel into `conversation.sock` as JSONL. Lazy connect
497/// on the first frame; on failure (daemon absent) frames are dropped
498/// and reconnect attempts are gated to once per second — cheap
499/// recovery after a daemon restart without a per-row connect storm.
500async fn rx_task(state_dir: PathBuf, mut rx: mpsc::UnboundedReceiver<TeeFrame>) {
501    let mut write: Option<tokio::io::WriteHalf<LocalSocketStream>> = None;
502    let mut last_attempt: Option<Instant> = None;
503    while let Some(frame) = rx.recv().await {
504        let Ok(mut line) = serde_json::to_vec(&frame) else {
505            continue;
506        };
507        line.push(b'\n');
508        if write.is_none() {
509            let due = last_attempt
510                .map(|at| at.elapsed() >= Duration::from_secs(1))
511                .unwrap_or(true);
512            if due {
513                last_attempt = Some(Instant::now());
514                write = connect(&state_dir).await;
515            }
516        }
517        let Some(sink) = write.as_mut() else {
518            // No daemon reachable — drop the frame (best-effort).
519            continue;
520        };
521        if sink.write_all(&line).await.is_err() || sink.flush().await.is_err() {
522            // Connection died (daemon restart) — drop this frame;
523            // later frames retry through the 1s gate above.
524            write = None;
525        }
526    }
527}
528
529/// Connect to the daemon's conversation socket. The ONE retried error
530/// is Windows `ERROR_PIPE_BUSY` (a live listener mid-accept), same
531/// rationale as `daemon_stream::connect_feed`.
532async fn connect(state_dir: &Path) -> Option<tokio::io::WriteHalf<LocalSocketStream>> {
533    const ERROR_PIPE_BUSY: i32 = 231;
534    let mut attempts = 0u32;
535    loop {
536        let name = socket_name(state_dir).ok()?;
537        match LocalSocketStream::connect(name).await {
538            Ok(conn) => {
539                let (_read_half, write_half) = tokio::io::split(conn);
540                return Some(write_half);
541            }
542            Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY) && attempts < 20 => {
543                attempts += 1;
544                tokio::time::sleep(Duration::from_millis(5)).await;
545            }
546            Err(_) => return None,
547        }
548    }
549}