salamander-db 0.1.3

Embedded event-sourcing engine with instant recovery — the append-only log is the only durable structure.
Documentation
//! DESIGN.md §5 — SessionProjection: per-namespace agent session view. The
//! branching-session demo workhorse. Namespace filtering happens in `apply` —
//! the projection ignores events from other namespaces.
//!
//! A projection over `EventBody`: its `Projection::Body` associated type
//! pins it to an `AgentDb`, so it can only be built from a log carrying the
//! agent vocabulary.

use std::collections::HashMap;

use super::{EventBody, Role};
use crate::event::Event;
use crate::projection::{NamespaceScoped, Projection};

/// One entry in a session's reconstructed transcript, in application
/// order.
#[derive(Debug, Clone)]
pub enum TranscriptEntry {
    /// A conversational turn.
    ModelTurn {
        /// Who produced the turn.
        role: Role,
        /// The turn's text content.
        content: String,
        /// The model that produced it.
        model: String,
    },
    /// A tool invocation.
    ToolCall {
        /// Correlates this call with its result.
        call_id: String,
        /// Name of the tool invoked.
        tool: String,
        /// JSON-encoded arguments.
        args_json: String,
    },
    /// The result of a tool invocation.
    ToolResult {
        /// The `call_id` of the originating call.
        call_id: String,
        /// Whether the call succeeded.
        ok: bool,
        /// The result content.
        content: String,
    },
    /// A recorded decision.
    Decision {
        /// One-line summary.
        summary: String,
        /// Why it was made.
        rationale: String,
    },
}

/// The lifecycle status of a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionStatus {
    /// No `SessionStarted` event has been seen for this namespace.
    NotStarted,
    /// The session has started and not yet ended.
    Active,
    /// The session has ended.
    Ended {
        /// Why the session ended.
        reason: String,
    },
}

/// A tool call awaiting its result, keyed by `call_id` in
/// [`SessionState::pending`].
#[derive(Debug, Clone)]
pub struct PendingToolCall {
    /// Name of the tool invoked.
    pub tool: String,
    /// JSON-encoded arguments.
    pub args_json: String,
}

/// The derived state of one agent session: its transcript, outstanding
/// tool calls, and status.
#[derive(Debug)]
pub struct SessionState {
    /// Set from this session's own `SessionStarted` event, if it's been
    /// applied yet. `None` until then (or for a namespace that never
    /// started a session at all).
    pub agent_id: Option<String>,
    /// The reconstructed transcript in application order.
    pub transcript: Vec<TranscriptEntry>,
    /// Tool calls seen without a matching result yet, keyed by `call_id`.
    pub pending: HashMap<String, PendingToolCall>,
    /// The session's lifecycle status.
    pub status: SessionStatus,
}

impl Default for SessionState {
    fn default() -> Self {
        Self {
            agent_id: None,
            transcript: Vec::new(),
            pending: HashMap::new(),
            status: SessionStatus::NotStarted,
        }
    }
}

/// A [`Projection`] that folds the agent vocabulary for a single namespace
/// into a [`SessionState`] — the session transcript and status. Events from
/// other namespaces are skipped.
pub struct SessionProjection {
    namespace: String,
    state: SessionState,
    cursor: u64,
}

impl SessionProjection {
    /// Creates an empty projection scoped to `namespace`.
    pub fn new(namespace: impl Into<String>) -> Self {
        Self {
            namespace: namespace.into(),
            state: SessionState::default(),
            cursor: 0,
        }
    }
}

impl Projection for SessionProjection {
    type Body = EventBody;
    type State = SessionState;

    fn apply(&mut self, event: &Event<EventBody>) {
        if event.namespace != self.namespace {
            self.cursor = event.offset + 1;
            return;
        }

        match &event.body {
            EventBody::SessionStarted { agent_id, .. } => {
                self.state.status = SessionStatus::Active;
                self.state.agent_id = Some(agent_id.clone());
            }
            EventBody::ModelTurn {
                role,
                content,
                model,
            } => {
                self.state.transcript.push(TranscriptEntry::ModelTurn {
                    role: *role,
                    content: content.clone(),
                    model: model.clone(),
                });
            }
            EventBody::ToolCall {
                call_id,
                tool,
                args_json,
            } => {
                self.state.pending.insert(
                    call_id.clone(),
                    PendingToolCall {
                        tool: tool.clone(),
                        args_json: args_json.clone(),
                    },
                );
                self.state.transcript.push(TranscriptEntry::ToolCall {
                    call_id: call_id.clone(),
                    tool: tool.clone(),
                    args_json: args_json.clone(),
                });
            }
            EventBody::ToolResult {
                call_id,
                ok,
                content,
            } => {
                self.state.pending.remove(call_id);
                self.state.transcript.push(TranscriptEntry::ToolResult {
                    call_id: call_id.clone(),
                    ok: *ok,
                    content: content.clone(),
                });
            }
            EventBody::Decision { summary, rationale } => {
                self.state.transcript.push(TranscriptEntry::Decision {
                    summary: summary.clone(),
                    rationale: rationale.clone(),
                });
            }
            EventBody::SessionEnded { reason } => {
                self.state.status = SessionStatus::Ended {
                    reason: reason.clone(),
                };
            }
            EventBody::Put { .. } | EventBody::Delete { .. } => {}
        }

        self.cursor = event.offset + 1;
    }

    fn cursor(&self) -> u64 {
        self.cursor
    }

    fn state(&self) -> &Self::State {
        &self.state
    }
}

impl NamespaceScoped for SessionProjection {
    fn new_for(namespace: &str) -> Self {
        SessionProjection::new(namespace)
    }
}