haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
use serde::Deserialize;
use std::path::{Path, PathBuf};

/// A launch declaration and its positive resource bounds. No field grants permission.
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Harness {
    /// One catalogue id; cdx uses channel wake instead of this host.
    pub id: String,
    /// The executable, resolved at launch without searching the current directory.
    pub program: PathBuf,
    /// Arguments passed without a shell. They never enter diagnostics.
    pub args: Vec<String>,
    /// Additional inherited names; values are supplied by the launch.
    #[serde(default)]
    pub environment: Vec<String>,
    /// One unread frame keeps the read side draining without retaining a burst.
    #[serde(default = "reads")]
    pub read_queue: usize,
    /// Initialization allows a cold adapter start while bounding a silent peer.
    #[serde(default = "deadline_ms")]
    pub initialize_timeout_ms: u64,
    /// Session setup is bounded separately from a permission held for a person.
    #[serde(default = "deadline_ms")]
    pub session_timeout_ms: u64,
    /// Session root; absent inherits the directory selected by the Lys launch profile.
    pub cwd: Option<PathBuf>,
    /// A prior session explicitly requested for resume, never silently replaced.
    pub session: Option<String>,
    /// Unread prompts; 32 is one seat's unread mail, not a durable mailbox.
    #[serde(default = "prompt_queue")]
    pub prompt_queue: usize,
    /// Text bytes per stream item and prompt; 64 KiB covers one model turn, not a file transfer.
    #[serde(default = "content_bytes")]
    pub content_bytes: usize,
    /// ACP frame bytes; 256 KiB admits tool metadata without permitting an unbounded pipe allocation.
    #[serde(default = "frame_bytes")]
    pub acp_frame_bytes: usize,
    /// Held permissions; 1024 admits the 729-request pipe contract while bounding approval memory.
    #[serde(default = "permissions")]
    pub pending_permissions: usize,
    /// Pipe responses awaiting write; 2048 covers a permission burst without blocking stdout reads.
    #[serde(default = "writes")]
    pub write_queue: usize,
    /// Stream publications awaiting the network; 128 limits memory when a watcher is slow.
    #[serde(default = "streams")]
    pub stream_queue: usize,
    /// Issuer response bytes; 64 KiB admits structured refusals without retaining arbitrary bodies.
    #[serde(default = "content_bytes")]
    pub issuer_response_bytes: usize,
    /// Fifty conversations bound one history response.
    #[serde(default = "sessions")]
    pub session_list: usize,
    /// Titles need enough words to distinguish conversations in a picker.
    #[serde(default = "titles")]
    pub title_chars: usize,
}

const fn sessions() -> usize {
    50
}
const fn titles() -> usize {
    120
}

const fn prompt_queue() -> usize {
    32
}
const fn content_bytes() -> usize {
    65_536
}
const fn frame_bytes() -> usize {
    262_144
}
const fn permissions() -> usize {
    1024
}
const fn writes() -> usize {
    2048
}
const fn streams() -> usize {
    128
}

impl Harness {
    /// Checks catalogue membership, paths, and all positive limits before spawning.
    ///
    /// # Errors
    /// Returns a refusal naming the invalid field, without its value.
    pub fn validate(&self) -> anyhow::Result<()> {
        super::catalogue::validate(&self.id)?;
        super::environment::validate(&self.environment)?;
        anyhow::ensure!(
            self.initialize_timeout_ms > 0,
            "harness.initialize_timeout_ms must be positive"
        );
        anyhow::ensure!(
            self.session_timeout_ms > 0,
            "harness.session_timeout_ms must be positive"
        );
        anyhow::ensure!(
            !self.program.as_os_str().is_empty(),
            "harness.program is empty"
        );
        anyhow::ensure!(
            self.cwd.as_ref().is_none_or(|cwd| cwd.is_absolute()),
            "harness.cwd must be absolute"
        );
        anyhow::ensure!(
            self.session
                .as_ref()
                .is_none_or(|id| !id.trim().is_empty() && id.len() <= self.content_bytes),
            "harness.session is empty or exceeds content_bytes"
        );
        for (name, bound) in [
            ("session_list", self.session_list),
            ("title_chars", self.title_chars),
            ("prompt_queue", self.prompt_queue),
            ("content_bytes", self.content_bytes),
            ("acp_frame_bytes", self.acp_frame_bytes),
            ("pending_permissions", self.pending_permissions),
            ("read_queue", self.read_queue),
            ("write_queue", self.write_queue),
            ("stream_queue", self.stream_queue),
            ("issuer_response_bytes", self.issuer_response_bytes),
        ] {
            anyhow::ensure!(bound > 0, "harness.{name} must be positive");
        }
        for (name, bound) in [
            ("read_queue", self.read_queue),
            ("write_queue", self.write_queue),
            ("stream_queue", self.stream_queue),
        ] {
            anyhow::ensure!(
                bound <= tokio::sync::Semaphore::MAX_PERMITS,
                "harness.{name} exceeds channel capacity"
            );
        }
        Ok(())
    }
}

#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Seat {
    #[serde(skip)]
    pub path: PathBuf,
    pub harness: Harness,
    pub door_socket: PathBuf,
    pub serve_config: PathBuf,
    pub key_file: PathBuf,
    pub issuer: String,
    pub store: String,
}

impl Seat {
    pub fn load(path: &Path) -> anyhow::Result<Self> {
        let bytes = std::fs::read(path)
            .map_err(|error| anyhow::anyhow!("seat_unreadable: {}", error.kind()))?;
        let mut decoder = serde_json::Deserializer::from_slice(&bytes);
        let mut seat: Self = serde_path_to_error::deserialize(&mut decoder).map_err(|error| {
            let field: String = error
                .path()
                .to_string()
                .chars()
                .take(160)
                .filter(|character| {
                    character.is_ascii_alphanumeric() || ".[]_".contains(*character)
                })
                .collect();
            anyhow::anyhow!(
                "seat_invalid: field {field}, line {}, column {}, {:?}",
                error.inner().line(),
                error.inner().column(),
                error.inner().classify()
            )
        })?;
        decoder.end().map_err(|error| {
            anyhow::anyhow!(
                "seat_invalid: trailing input, line {}, column {}",
                error.line(),
                error.column()
            )
        })?;
        seat.path = std::fs::canonicalize(path)?;
        seat.harness.validate()?;
        anyhow::ensure!(
            seat.door_socket.is_absolute()
                && seat.serve_config.is_absolute()
                && seat.key_file.is_absolute(),
            "seat_paths_must_be_absolute"
        );
        anyhow::ensure!(
            !seat.store.is_empty() && !seat.issuer.is_empty(),
            "seat_store_and_issuer_required"
        );
        Ok(seat)
    }
    pub fn cwd(&self) -> anyhow::Result<PathBuf> {
        self.harness
            .cwd
            .clone()
            .map_or_else(|| Ok(std::env::current_dir()?), Ok)
    }
}

const fn reads() -> usize {
    1
}
const fn deadline_ms() -> u64 {
    30_000
}