Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-core/src/trace.rs

Raw
//! Replay evidence derived from durable session state (`SMH-SPEC-SPEC0001`,
//! Sessions).
//!
//! A trace is an ordered, provider-free reconstruction of what a session
//! recorded. It is derived only from persisted entry records, never from
//! decoded content, so the same session file always produces the same trace,
//! byte for byte.

use crate::session::{
    COMPACTION_KIND, EntryContent, EntryFrame, EntryKind, SessionHeader, TOOL_MUTATION_KIND,
};
use crate::store::SessionReader;
use serde::Deserialize;
use smith::error::Result;
use smith::id::{SessionId, ToolCallId};

/// Replay-relevant trace record (durable replay evidence).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct TraceRecord {
    /// Timestamp of the recorded event, in Unix milliseconds.
    pub timestamp_ms: i64,
    /// Session the event belongs to.
    pub session_id: SessionId,
    /// Event kind.
    pub kind: TraceKind,
    /// Stable reference to the recorded event.
    pub event_ref: String,
}

/// Kind of replay event.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum TraceKind {
    /// Session created.
    SessionCreated,
    /// Entry added.
    EntryAdded,
    /// Message streamed.
    MessageStreamed,
    /// Tool call emitted.
    ToolCallEmitted,
    /// Raw tool result observed; not authoritative effect completion.
    ToolResultReceived,
    /// Authoritative tool completion: explicit durable metadata or a replayed
    /// incomplete lifecycle synthesized as `unknown`.
    ToolMutationCompleted,
    /// Compaction occurred.
    Compaction,
    /// Replay started.
    ReplayStarted,
    /// Replay completed.
    ReplayCompleted,
}

/// Visit durable trace records for the selected history in source order.
///
/// Each entry contributes the record kind matching what it recorded, so tool
/// activity and compaction remain visible rather than collapsing into a
/// generic entry event. A raw result is authoritative only when its matching
/// mutation metadata is present; otherwise replay emits a completed `unknown`
/// mutation record. One pending lifecycle is retained while history is streamed
/// through [`SessionReader::replay_at`], never a whole-history call map.
///
/// # Errors
///
/// Returns a reader validation, storage, ancestry, or visitor error.
pub fn trace(
    reader: &SessionReader,
    frame_bytes_max: usize,
    visit: impl FnMut(TraceRecord) -> Result<()>,
) -> Result<()> {
    let header = reader.header(frame_bytes_max)?;
    trace_selected(reader, &header, frame_bytes_max, visit).map(|_| ())
}

/// Replay durable selected history without a provider, yielding each record
/// in source order.
///
/// Replay is framed by exactly one start and completion record. Its completion
/// timestamp and entry count derive from the durable selected tip, never live
/// effective context.
///
/// # Errors
///
/// Returns a reader validation, storage, ancestry, or visitor error.
pub fn replay(
    reader: &SessionReader,
    frame_bytes_max: usize,
    mut visit: impl FnMut(TraceRecord) -> Result<()>,
) -> Result<()> {
    let header = reader.header(frame_bytes_max)?;
    visit(TraceRecord {
        timestamp_ms: 0,
        session_id: header.session_id,
        kind: TraceKind::ReplayStarted,
        event_ref: format!("session:{}", header.session_id),
    })?;
    let (count, timestamp_ms) = trace_selected(reader, &header, frame_bytes_max, &mut visit)?;
    visit(TraceRecord {
        timestamp_ms,
        session_id: header.session_id,
        kind: TraceKind::ReplayCompleted,
        event_ref: format!("entries:{count}"),
    })
}

fn trace_selected(
    reader: &SessionReader,
    header: &SessionHeader,
    frame_bytes_max: usize,
    mut visit: impl FnMut(TraceRecord) -> Result<()>,
) -> Result<(usize, i64)> {
    visit(TraceRecord {
        timestamp_ms: 0,
        session_id: header.session_id,
        kind: TraceKind::SessionCreated,
        event_ref: format!("branch:{}", header.branch_id),
    })?;
    let Some(tip) = reader.selected_tip(frame_bytes_max)? else {
        return Ok((0, 0));
    };
    let mut count = 0;
    let mut timestamp_ms = 0;
    // `(call_id, has_result)`: exactly one live tool lifecycle.
    // ToolSession serializes effects and refuses subsequent effects after a
    // completion-recording failure, so another lifecycle closes the prior one.
    let mut pending = None;
    reader.replay_at(tip, frame_bytes_max, |entry| {
        timestamp_ms = entry.timestamp_ms;
        count += 1;
        match entry.kind {
            EntryKind::ToolCall { call_id } => {
                flush_incomplete(
                    header.session_id,
                    entry.timestamp_ms,
                    &mut pending,
                    &mut visit,
                )?;
                visit(record_for(header.session_id, &entry)?)?;
                pending = Some((call_id, false));
            }
            EntryKind::ToolResult { call_id, .. } => {
                if pending
                    .is_some_and(|(pending_id, has_result)| pending_id != call_id || has_result)
                {
                    flush_incomplete(
                        header.session_id,
                        entry.timestamp_ms,
                        &mut pending,
                        &mut visit,
                    )?;
                }
                visit(record_for(header.session_id, &entry)?)?;
                pending = Some((call_id, true));
            }
            EntryKind::Meta { .. } if mutation_call_id(&entry)? == pending.map(|(id, _)| id) => {
                visit(record_for(header.session_id, &entry)?)?;
                pending = None;
            }
            EntryKind::Meta { .. } | EntryKind::Message { .. } => {
                visit(record_for(header.session_id, &entry)?)?;
            }
        }
        Ok(())
    })?;
    flush_incomplete(header.session_id, timestamp_ms, &mut pending, &mut visit)?;
    Ok((count, timestamp_ms))
}

fn flush_incomplete(
    session_id: SessionId,
    timestamp_ms: i64,
    pending: &mut Option<(smith::id::ToolCallId, bool)>,
    visit: &mut impl FnMut(TraceRecord) -> Result<()>,
) -> Result<()> {
    if let Some((call_id, _)) = pending.take() {
        visit(TraceRecord {
            timestamp_ms,
            session_id,
            kind: TraceKind::ToolMutationCompleted,
            event_ref: format!("mutation:{call_id}:unknown"),
        })?;
    }
    Ok(())
}

fn mutation_call_id(entry: &EntryFrame) -> Result<Option<ToolCallId>> {
    let EntryContent::Meta { kind, detail } = EntryContent::decode(&entry.content)? else {
        return Ok(None);
    };
    if kind != TOOL_MUTATION_KIND {
        return Ok(None);
    }
    let detail: MutationDetail =
        detail
            .deserialize_as()
            .map_err(|error| smith::error::SmithError::Session {
                code: smith::error::SessionCode::EntryDecode,
                message: format!("invalid tool mutation state metadata: {error}"),
            })?;
    Ok(Some(detail.call_id))
}

#[derive(Deserialize)]
struct MutationDetail {
    call_id: ToolCallId,
    mutation_state: smith::error::MutationState,
}

fn record_for(session_id: SessionId, entry: &EntryFrame) -> Result<TraceRecord> {
    let (kind, event_ref) = match entry.kind {
        EntryKind::Message { id, .. } => (TraceKind::MessageStreamed, format!("message:{id}")),
        EntryKind::ToolCall { call_id } => (TraceKind::ToolCallEmitted, format!("call:{call_id}")),
        EntryKind::ToolResult { call_id, ok } => (
            TraceKind::ToolResultReceived,
            format!("result:{call_id}:{}", if ok { "ok" } else { "failed" }),
        ),
        EntryKind::Meta { compaction: true } => (
            TraceKind::Compaction,
            format!("meta:{COMPACTION_KIND}:{}", entry.id),
        ),
        EntryKind::Meta { compaction: false } => match EntryContent::decode(&entry.content)? {
            EntryContent::Meta { kind, detail } if kind == TOOL_MUTATION_KIND => {
                let detail: MutationDetail =
                    detail
                        .deserialize_as()
                        .map_err(|error| smith::error::SmithError::Session {
                            code: smith::error::SessionCode::EntryDecode,
                            message: format!("invalid tool mutation state metadata: {error}"),
                        })?;
                (
                    TraceKind::ToolMutationCompleted,
                    format!("mutation:{}:{}", detail.call_id, detail.mutation_state),
                )
            }
            _ => (TraceKind::EntryAdded, format!("meta:{}", entry.id)),
        },
    };
    Ok(TraceRecord {
        timestamp_ms: entry.timestamp_ms,
        session_id,
        kind,
        event_ref,
    })
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "tests may panic on invariant violations"
)]
mod tests {
    use super::*;
    use crate::session::EntryContent;
    use crate::store::{SessionReader, open_session};
    use crate::tools::ToolSession;
    use smith::config::Config;
    use smith::error::{MutationState, SmithError, ToolCode};
    use smith::id::ToolCallId;
    use smith::message::{Message, Role};
    use smith::{StructuredData, ToolArguments};

    fn kinds(trace: &[TraceRecord]) -> Vec<TraceKind> {
        trace.iter().map(|record| record.kind).collect()
    }

    fn replayed(reader: &SessionReader, frame_bytes_max: usize) -> Vec<TraceRecord> {
        let mut records = Vec::new();
        replay(reader, frame_bytes_max, |record| {
            records.push(record);
            Ok(())
        })
        .unwrap();
        records
    }

    #[test]
    fn durable_replay_and_fork_keep_pre_compaction_history() {
        let dir =
            std::env::temp_dir().join(format!("smith_trace_compaction_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("source.smh");
        let fork_path = dir.join("fork.smh");
        let config = Config::default_valid();
        let opened = open_session(&path, config.clone()).unwrap();
        let mut tools = ToolSession::with_session(&dir, opened.session, opened.writer);
        let message = tools
            .record(&EntryContent::Message(Message::with_text(
                Role::User,
                "before",
            )))
            .unwrap();
        let call = tools
            .record(&EntryContent::ToolCall {
                name: "read".to_string(),
                input: ToolArguments::from_serializable(&serde_json::json!({"path": "note.txt"}))
                    .unwrap(),
                call_id: ToolCallId::from_u128(1),
            })
            .unwrap();
        let result = tools
            .record(&EntryContent::ToolResult {
                ok: true,
                output: "note".to_string(),
                call_id: ToolCallId::from_u128(1),
            })
            .unwrap();
        let compaction = tools
            .record(&EntryContent::Meta {
                kind: COMPACTION_KIND.to_string(),
                detail: StructuredData::from_serializable(
                    &serde_json::json!({"summary": "before"}),
                )
                .unwrap(),
            })
            .unwrap();
        let after = tools
            .record(&EntryContent::Message(Message::with_text(
                Role::Assistant,
                "after",
            )))
            .unwrap();
        assert_eq!(tools.session.active_branch().entries().len(), 2);

        let reader = SessionReader::open(&path).unwrap();
        let mut records = Vec::new();
        replay(&reader, config.frame_bytes_max, |record| {
            records.push(record);
            Ok(())
        })
        .unwrap();
        assert_eq!(
            kinds(&records),
            vec![
                TraceKind::ReplayStarted,
                TraceKind::SessionCreated,
                TraceKind::MessageStreamed,
                TraceKind::ToolCallEmitted,
                TraceKind::ToolResultReceived,
                TraceKind::Compaction,
                TraceKind::MessageStreamed,
                TraceKind::ToolMutationCompleted,
                TraceKind::ReplayCompleted,
            ]
        );
        assert_eq!(
            records
                .iter()
                .filter(|record| record.kind == TraceKind::ReplayStarted)
                .count(),
            1
        );
        assert_eq!(
            records
                .iter()
                .filter(|record| record.kind == TraceKind::ReplayCompleted)
                .count(),
            1
        );
        assert_eq!(records.last().unwrap().timestamp_ms, after.timestamp_ms);
        assert_eq!(records.last().unwrap().event_ref, "entries:5");

        let forked = reader
            .fork_at(after.id, &fork_path, config.clone())
            .unwrap();
        assert_eq!(forked.session.selected_id(), Some(after.id));
        let fork_reader = SessionReader::open(&fork_path).unwrap();
        let mut forked_ids = Vec::new();
        fork_reader
            .replay_at(after.id, config.frame_bytes_max, |entry| {
                forked_ids.push(entry.id);
                Ok(())
            })
            .unwrap();
        assert_eq!(
            forked_ids,
            vec![message.id, call.id, result.id, compaction.id, after.id]
        );
        drop(forked);
        drop(tools);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn replay_marks_result_without_durable_completion_unknown_after_third_append_fails() {
        let dir = std::env::temp_dir().join(format!(
            "smith_trace_incomplete_completion_{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("trace.smh");
        let config = Config::default_valid();
        let opened = open_session(&path, config.clone()).unwrap();
        let mut writer = opened.writer;
        let mut appends = 0;
        let mut tools = ToolSession::with_persist(&dir, opened.session, move |entry, bytes| {
            appends += 1;
            if appends == 3 {
                return Err(SmithError::Tool {
                    code: ToolCode::TestRecord,
                    message: "third append rejected".to_string(),
                });
            }
            writer.append_entry(entry, bytes)
        })
        .with_config(config.clone());
        let call_id = ToolCallId::from_u128(7);

        let error = tools
            .invoke_local(
                call_id,
                "write",
                &ToolArguments::from_serializable(
                    &serde_json::json!({"path": "made.txt", "content": "made"}),
                )
                .unwrap(),
                &ToolArguments::from_serializable(
                    &serde_json::json!({"path": "made.txt", "content": "made"}),
                )
                .unwrap(),
                str::to_string,
            )
            .unwrap_err();
        assert!(matches!(
            error,
            SmithError::RecordFailed {
                mutation_state: MutationState::Applied,
                ..
            }
        ));
        assert_eq!(
            std::fs::read_to_string(dir.join("made.txt")).unwrap(),
            "made"
        );
        drop(tools);

        let reader = SessionReader::open(&path).unwrap();
        let tip = reader
            .selected_tip(config.frame_bytes_max)
            .unwrap()
            .unwrap();
        let mut entries = Vec::new();
        reader
            .replay_at(tip, config.frame_bytes_max, |entry| {
                entries.push(entry);
                Ok(())
            })
            .unwrap();
        assert_eq!(entries.len(), 2);
        let header = reader.header(config.frame_bytes_max).unwrap();
        let records = replayed(&reader, config.frame_bytes_max);
        assert_eq!(
            records,
            vec![
                TraceRecord {
                    timestamp_ms: 0,
                    session_id: header.session_id,
                    kind: TraceKind::ReplayStarted,
                    event_ref: format!("session:{}", header.session_id),
                },
                TraceRecord {
                    timestamp_ms: 0,
                    session_id: header.session_id,
                    kind: TraceKind::SessionCreated,
                    event_ref: format!("branch:{}", header.branch_id),
                },
                TraceRecord {
                    timestamp_ms: entries[0].timestamp_ms,
                    session_id: header.session_id,
                    kind: TraceKind::ToolCallEmitted,
                    event_ref: format!("call:{call_id}"),
                },
                TraceRecord {
                    timestamp_ms: entries[1].timestamp_ms,
                    session_id: header.session_id,
                    kind: TraceKind::ToolResultReceived,
                    event_ref: format!("result:{call_id}:ok"),
                },
                TraceRecord {
                    timestamp_ms: entries[1].timestamp_ms,
                    session_id: header.session_id,
                    kind: TraceKind::ToolMutationCompleted,
                    event_ref: format!("mutation:{call_id}:unknown"),
                },
                TraceRecord {
                    timestamp_ms: entries[1].timestamp_ms,
                    session_id: header.session_id,
                    kind: TraceKind::ReplayCompleted,
                    event_ref: "entries:2".to_string(),
                },
            ]
        );
        drop(reader);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn replay_marks_call_without_result_unknown() {
        let dir = std::env::temp_dir().join(format!(
            "smith_trace_incomplete_call_{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("trace.smh");
        let config = Config::default_valid();
        let opened = open_session(&path, config.clone()).unwrap();
        let session_id = opened.session.id();
        let branch_id = opened.session.active_branch().id();
        let mut tools = ToolSession::with_session(&dir, opened.session, opened.writer);
        let call_id = ToolCallId::from_u128(8);
        let call = tools
            .record(&EntryContent::ToolCall {
                name: "write".to_string(),
                input: ToolArguments::from_serializable(
                    &serde_json::json!({"path": "never-retry.txt", "content": "x"}),
                )
                .unwrap(),
                call_id,
            })
            .unwrap();
        drop(tools);

        let reader = SessionReader::open(&path).unwrap();
        let mut records = Vec::new();
        trace(&reader, config.frame_bytes_max, |record| {
            records.push(record);
            Ok(())
        })
        .unwrap();
        assert_eq!(
            records,
            vec![
                TraceRecord {
                    timestamp_ms: 0,
                    session_id,
                    kind: TraceKind::SessionCreated,
                    event_ref: format!("branch:{branch_id}"),
                },
                TraceRecord {
                    timestamp_ms: call.timestamp_ms,
                    session_id,
                    kind: TraceKind::ToolCallEmitted,
                    event_ref: format!("call:{call_id}"),
                },
                TraceRecord {
                    timestamp_ms: call.timestamp_ms,
                    session_id,
                    kind: TraceKind::ToolMutationCompleted,
                    event_ref: format!("mutation:{call_id}:unknown"),
                },
            ]
        );
        drop(reader);
        assert!(!dir.join("never-retry.txt").exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn replay_looks_past_unrelated_entries_for_completion_metadata() {
        let dir = std::env::temp_dir().join(format!(
            "smith_trace_completion_lookahead_{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("trace.smh");
        let config = Config::default_valid();
        let opened = open_session(&path, config.clone()).unwrap();
        let session_id = opened.session.id();
        let branch_id = opened.session.active_branch().id();
        let mut tools = ToolSession::with_session(&dir, opened.session, opened.writer);
        let call_id = ToolCallId::from_u128(9);
        let call = tools
            .record(&EntryContent::ToolCall {
                name: "write".to_string(),
                input: ToolArguments::from_serializable(
                    &serde_json::json!({"path": "note.txt", "content": "x"}),
                )
                .unwrap(),
                call_id,
            })
            .unwrap();
        let result = tools
            .record(&EntryContent::ToolResult {
                ok: true,
                output: "wrote note.txt".to_string(),
                call_id,
            })
            .unwrap();
        let message = Message::with_text(Role::Assistant, "interleaved");
        let message_id = message.id;
        let interleaved = tools.record(&EntryContent::Message(message)).unwrap();
        let completion = tools
            .record(&EntryContent::Meta {
                kind: TOOL_MUTATION_KIND.to_string(),
                detail: StructuredData::from_serializable(
                    &serde_json::json!({"call_id": call_id, "mutation_state": "applied"}),
                )
                .unwrap(),
            })
            .unwrap();
        drop(tools);

        let reader = SessionReader::open(&path).unwrap();
        let mut records = Vec::new();
        trace(&reader, config.frame_bytes_max, |record| {
            records.push(record);
            Ok(())
        })
        .unwrap();
        assert_eq!(
            records,
            vec![
                TraceRecord {
                    timestamp_ms: 0,
                    session_id,
                    kind: TraceKind::SessionCreated,
                    event_ref: format!("branch:{branch_id}")
                },
                TraceRecord {
                    timestamp_ms: call.timestamp_ms,
                    session_id,
                    kind: TraceKind::ToolCallEmitted,
                    event_ref: format!("call:{call_id}")
                },
                TraceRecord {
                    timestamp_ms: result.timestamp_ms,
                    session_id,
                    kind: TraceKind::ToolResultReceived,
                    event_ref: format!("result:{call_id}:ok")
                },
                TraceRecord {
                    timestamp_ms: interleaved.timestamp_ms,
                    session_id,
                    kind: TraceKind::MessageStreamed,
                    event_ref: format!("message:{message_id}")
                },
                TraceRecord {
                    timestamp_ms: completion.timestamp_ms,
                    session_id,
                    kind: TraceKind::ToolMutationCompleted,
                    event_ref: format!("mutation:{call_id}:applied")
                },
            ]
        );
        drop(reader);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn replaying_a_persisted_session_is_byte_stable() {
        let dir = std::env::temp_dir().join(format!("smith_trace_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("trace.smh");
        let cfg = Config::default_valid();
        {
            let opened = open_session(&path, cfg.clone()).unwrap();
            let mut tools = ToolSession::with_session(&dir, opened.session, opened.writer)
                .with_config(cfg.clone());
            tools
                .invoke(
                    "write",
                    &ToolArguments::from_serializable(
                        &serde_json::json!({"path": "note.txt", "content": "hi"}),
                    )
                    .unwrap(),
                )
                .unwrap();
            tools
                .invoke(
                    "read",
                    &ToolArguments::from_serializable(&serde_json::json!({"path": "note.txt"}))
                        .unwrap(),
                )
                .unwrap();
        }
        let first = SessionReader::open(&path).unwrap();
        let second = SessionReader::open(&path).unwrap();
        let left = replayed(&first, cfg.frame_bytes_max);
        let right = replayed(&second, cfg.frame_bytes_max);
        assert_eq!(left, right);
        assert_eq!(
            kinds(&replayed(&first, cfg.frame_bytes_max)),
            vec![
                TraceKind::ReplayStarted,
                TraceKind::SessionCreated,
                TraceKind::ToolCallEmitted,
                TraceKind::ToolResultReceived,
                TraceKind::ToolMutationCompleted,
                TraceKind::ToolCallEmitted,
                TraceKind::ToolResultReceived,
                TraceKind::ToolMutationCompleted,
                TraceKind::ReplayCompleted,
            ]
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}