theater 0.3.5

A WebAssembly actor system for AI agents
Documentation
//! # Chain Writer
//!
//! Streams actor events to disk in the EVENT format for crash recovery and debugging.
//! Each actor run gets its own chain file at `/tmp/theater/chains/{actor_id}.chain`.

use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::chain::ChainEvent;
use crate::TheaterId;

/// Metadata about a run, written to `{actor_id}.meta.json`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunMeta {
    pub actor_id: String,
    pub actor_name: Option<String>,
    pub manifest_path: Option<String>,
    pub started_at: u64,
}

/// Writes events to a `.chain` file using the EVENT format.
///
/// The EVENT format is:
/// ```text
/// EVENT <hash-hex>
/// <parent-hash-hex or 0000000000000000>
/// <event-type>
/// <body-length>
///
/// <body>
///
/// ```
pub struct ChainWriter {
    writer: BufWriter<File>,
    path: PathBuf,
}

impl ChainWriter {
    /// Creates a new chain writer for the given actor.
    ///
    /// Creates the chains directory if needed and opens the chain file.
    pub fn new(actor_id: &TheaterId) -> Result<Self> {
        let chains_dir = Self::chains_dir();
        fs::create_dir_all(&chains_dir)
            .with_context(|| format!("Failed to create chains directory: {:?}", chains_dir))?;

        let path = chains_dir.join(format!("{}.chain", actor_id));
        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)
            .with_context(|| format!("Failed to open chain file: {:?}", path))?;

        Ok(Self {
            writer: BufWriter::new(file),
            path,
        })
    }

    /// Returns the directory where chain files are stored.
    pub fn chains_dir() -> PathBuf {
        PathBuf::from("/tmp/theater/chains")
    }

    /// Writes metadata about this run.
    pub fn write_meta(actor_id: &TheaterId, meta: &RunMeta) -> Result<()> {
        let chains_dir = Self::chains_dir();
        fs::create_dir_all(&chains_dir)?;

        let path = chains_dir.join(format!("{}.meta.json", actor_id));
        let json = serde_json::to_string_pretty(meta)?;
        fs::write(&path, json)?;
        Ok(())
    }

    /// Appends an event to the chain file.
    ///
    /// Writes the event in EVENT format and flushes immediately
    /// for crash safety.
    pub fn append(&mut self, event: &ChainEvent) -> Result<()> {
        // EVENT <hash-hex>
        writeln!(self.writer, "EVENT {}", hex::encode(&event.hash))?;

        // <parent-hash-hex or zeros>
        match &event.parent_hash {
            Some(parent) => writeln!(self.writer, "{}", hex::encode(parent))?,
            None => writeln!(self.writer, "0000000000000000000000000000000000000000")?,
        }

        // <event-type>
        writeln!(self.writer, "{}", event.event_type)?;

        // <body-length>
        writeln!(self.writer, "{}", event.data.len())?;

        // blank line
        writeln!(self.writer)?;

        // <body>
        self.writer.write_all(&event.data)?;

        // blank line after body
        writeln!(self.writer)?;
        writeln!(self.writer)?;

        self.writer.flush()?;
        Ok(())
    }

    /// Returns the path to this chain file.
    pub fn path(&self) -> &PathBuf {
        &self.path
    }
}

impl Drop for ChainWriter {
    fn drop(&mut self) {
        // Ensure everything is flushed on drop
        let _ = self.writer.flush();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Read;

    #[test]
    fn test_chain_writer_append() {
        let actor_id = TheaterId::generate();
        let mut writer = ChainWriter::new(&actor_id).unwrap();

        let event = ChainEvent {
            hash: vec![0x1a, 0x2b, 0x3c],
            parent_hash: None,
            event_type: "test".to_string(),
            data: b"hello world".to_vec(),
        };

        writer.append(&event).unwrap();
        drop(writer);

        // Read back
        let path = ChainWriter::chains_dir().join(format!("{}.chain", actor_id));
        let mut file = File::open(&path).unwrap();
        let mut contents = String::new();
        file.read_to_string(&mut contents).unwrap();

        assert!(contents.contains("EVENT 1a2b3c"));
        assert!(contents.contains("0000000000000000000000000000000000000000"));
        assert!(contents.contains("test"));
        assert!(contents.contains("11")); // body length
        assert!(contents.contains("hello world"));

        // Cleanup
        fs::remove_file(&path).ok();
    }
}