nerve-ipc-core 0.1.1

Core IPC layer for the NERVE protocol: authentication, request lifecycle, transport-agnostic dispatch, Unix Domain Socket server, and WebSocket server.
Documentation
//! Transport-agnostic frame dispatch.
//!
//! [`dispatch_frame`] is the single function both transport layers call for
//! every received NERVE frame. It reads the decoded frame, updates the
//! [`crate::RequestTable`] as needed, and returns a [`DispatchAction`] telling
//! the transport what to do next.
//!
//! `dispatch_frame` performs no I/O. All socket reads and writes are the
//! responsibility of the calling transport layer ([`crate::server`] or
//! [`crate::ws_server`]).
//!
//! # AI daemon boundary
//!
//! When a `SearchQuery` frame is received, `dispatch_frame` registers the
//! request in the [`crate::RequestTable`] and returns
//! [`DispatchAction::ForwardToAiDaemon`]. The AI daemon (a future milestone)
//! is responsible for consuming the query, checking cancellation, and streaming
//! results back. The transport loop must not send any reply itself for this
//! variant.

use nerve_protocol::codec::encode;
use nerve_protocol::types::{FrameFlags, MessageType, RequestId};
use nerve_protocol::{Frame, ProtocolError};

use crate::request_table::RequestTable;

/// Outcome returned to the connection loop after dispatching a frame.
///
/// Marked `#[non_exhaustive]` because new variants will be added as the AI
/// daemon layer is built out. Downstream code must include a wildcard arm
/// when matching on this type.
///
/// # Examples
///
/// ```
/// use nerve_ipc_core::DispatchAction;
///
/// fn handle(action: DispatchAction) -> Option<Vec<u8>> {
///     match action {
///         DispatchAction::Handled => None,
///         DispatchAction::Reply(bytes) => Some(bytes),
///         DispatchAction::ForwardToAiDaemon(_) => None,
///         // Required: new variants will be added as the AI daemon is built out.
///         _ => None,
///     }
/// }
/// ```
#[non_exhaustive]
pub enum DispatchAction {
    /// Frame was handled; no response needs to be sent to the client.
    Handled,
    /// Frame generated a response that the transport must write back to the
    /// client. The bytes are a complete, encoded NERVE frame.
    Reply(Vec<u8>),
    /// A [`MessageType::SearchQuery`] was received. The request has been
    /// registered in the [`RequestTable`] so that a subsequent
    /// [`MessageType::Cancel`] can mark it cancelled. The AI daemon (a future
    /// milestone) is responsible for consuming and responding to this request.
    /// The enclosed [`RequestId`] is the ID already stored in the request table.
    ForwardToAiDaemon(RequestId),
}

/// Dispatch a single decoded frame and return the action to take.
///
/// This function is transport-agnostic: it does not perform any I/O.
/// The caller is responsible for sending any [`DispatchAction::Reply`] bytes
/// back to the client, whether over a Unix socket or a WebSocket.
///
/// Must be fast, non-blocking, and deterministic.
///
/// # Errors
///
/// Returns [`ProtocolError`] only when encoding the `Ping` reply fails. All
/// other message types either produce no reply or register state in the
/// [`RequestTable`] without encoding a response.
///
/// # Examples
///
/// ```
/// use nerve_ipc_core::{dispatch_frame, DispatchAction, RequestTable};
/// use nerve_protocol::codec::{decode, encode};
/// use nerve_protocol::types::{FrameFlags, MessageType, RequestId};
///
/// let bytes = encode(MessageType::Ping, FrameFlags::empty(), RequestId(1), b"").unwrap();
/// let frame = decode(&bytes).unwrap();
/// let mut table = RequestTable::new();
///
/// match dispatch_frame(frame, &mut table).unwrap() {
///     DispatchAction::Reply(_reply) => { /* send reply to the client */ }
///     DispatchAction::Handled => {}
///     DispatchAction::ForwardToAiDaemon(_) => {}
///     _ => {}
/// }
/// ```
pub fn dispatch_frame(
    frame: Frame<'_>,
    requests: &mut RequestTable,
) -> Result<DispatchAction, ProtocolError> {
    let msg_type = match MessageType::try_from(frame.header.msg_type) {
        Ok(t) => t,
        Err(_) => return Ok(DispatchAction::Handled),
    };

    match msg_type {
        MessageType::Ping => {
            let bytes = encode_ping_reply(frame)?;
            Ok(DispatchAction::Reply(bytes))
        }

        MessageType::Cancel => {
            handle_cancel(frame, requests);
            Ok(DispatchAction::Handled)
        }

        // SearchQuery belongs to the AI daemon layer. Register the request
        // here so cancellation works, then let the caller forward it.
        MessageType::SearchQuery => {
            let req_id = RequestId(frame.header.request_id);
            requests.insert(req_id);
            Ok(DispatchAction::ForwardToAiDaemon(req_id))
        }

        MessageType::AgentTaskStart => {
            let req_id = RequestId(frame.header.request_id);
            requests.insert(req_id);
            Ok(DispatchAction::Handled)
        }

        MessageType::AgentTaskEvent => Ok(DispatchAction::Handled),

        MessageType::AgentTaskDone => {
            let req_id = RequestId(frame.header.request_id);
            requests.remove(req_id);
            Ok(DispatchAction::Handled)
        }

        // AiToken / SearchResult flow from the AI daemon to the client.
        // Ignore if received in this direction.
        _ => Ok(DispatchAction::Handled),
    }
}

fn encode_ping_reply(frame: Frame<'_>) -> Result<Vec<u8>, ProtocolError> {
    encode(
        MessageType::Ping,
        FrameFlags::FINAL,
        RequestId(frame.header.request_id),
        frame.payload,
    )
}

fn handle_cancel(frame: Frame<'_>, requests: &mut RequestTable) {
    let req_id = RequestId(frame.header.request_id);
    requests.cancel(req_id);
}