cuttlefish-abi 0.0.3

Wire types shared by the cuttlefish wasm host and its guest proc-blocks
Documentation
//! The contract between the cuttlefish host and its guest proc-blocks.
//!
//! Both sides depend on this crate precisely so that they cannot drift: a block
//! is compiled separately from the host, often at a different time by a
//! different person, and the only thing keeping them able to talk is that they
//! agreed on these types.
//!
//! # Why a command loop, not function calls
//!
//! A block does not call the host. It *returns* a [`Command`] describing what it
//! wants done, and the host — after doing it — hands back an [`Event`] and asks
//! for the next command. Control is inverted relative to the obvious design, and
//! not for taste:
//!
//! - A core-wasm guest is single-threaded and offers no execution context the
//!   host could call back into while the guest is blocked. A "call the host and
//!   wait" design has nowhere to deliver the answer.
//! - Inference must run on a different thread from the wasm store, which is
//!   `!Sync` and cannot be touched from there.
//! - Because the host decides whether to take the next step, cancellation needs
//!   no cooperation from the guest at all: the host simply stops stepping. A
//!   guest cannot ignore, delay, or trap its way out of being cancelled.
//!
//! Everything crosses the boundary as JSON. That is slower than a packed binary
//! layout, deliberately: the boundary stays inspectable, a mismatch produces a
//! legible error rather than a misread integer, and the volume is low because
//! bulk data does not cross it. Revisit only if profiling says to.
//!
//! # Why bulk data does not cross this boundary
//!
//! No command hands a block the contents of a file. A block [`Command::Open`]s a
//! path, receives a [`Handle`] and a length, then pulls bounded windows with
//! [`Command::Slice`].
//!
//! This keeps guest memory proportional to the window a block chooses rather
//! than to the size of its input. A block written against a small file behaves
//! identically against a huge one, and the 4 GiB ceiling of 32-bit wasm stops
//! being something block authors must reason about — which is what lets this
//! project stay on `wasm32` instead of paying for `wasm64`.

#![forbid(unsafe_code)]
#![warn(missing_docs)]

use serde::{Deserialize, Serialize};

/// A job-scoped reference to something the host holds open for a guest.
///
/// Job-scoping is a security property, not bookkeeping. A handle table lives and
/// dies with a single job, so a handle from one job names nothing in another.
/// That is why [`Command::Slice`] carries no path and needs no capability check
/// of its own: the check happened once, at [`Command::Open`], and a handle
/// cannot be forged into a reference to another job's data.
pub type Handle = u32;

/// What kind of thing a handle refers to, reported by [`Event::Opened`].
///
/// A block needs this to know which commands are worth issuing: [`Command::Slice`]
/// on a PNG is a mistake, and [`Command::PageText`] on a plain text file is
/// meaningless. Reporting it up front means a block can branch on what it
/// actually got rather than guessing from a file extension.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MediaKind {
    /// Valid UTF-8. Both [`Command::Slice`] and [`Command::SliceBytes`] work.
    Text,
    /// An image the host recognised. Usable as an [`Command::Infer`] image.
    Image {
        /// Format as detected from content, e.g. `png`, `jpeg`.
        format: String,
    },
    /// A paged document — a PDF, say.
    Document {
        /// How many pages it has.
        pages: u32,
        /// Whether it carries an extractable text layer.
        ///
        /// False for a scanned document, where the only way to read it is to
        /// rasterize pages and hand them to a vision model. A block that checks
        /// this can pick the cheap path when it exists and the expensive one
        /// when it must, instead of silently extracting nothing.
        has_text_layer: bool,
    },
    /// Bytes the host could not classify. Only [`Command::SliceBytes`] applies.
    Binary,
}

/// What a guest asks the host to do, returned from its `init`/`step` exports.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
    /// Run a prompt against the job's model.
    Infer {
        /// The prompt to generate from.
        prompt: String,
        /// Upper bound on tokens generated. A guest can also end generation
        /// early by returning [`TokenAction::Stop`] from its `on_token` export.
        max_tokens: u32,
        /// Images to accompany the prompt, named by handle.
        ///
        /// Handles rather than bytes, for the same reason file contents are not
        /// handed over: an image can be tens of megabytes, and routing it
        /// through guest memory would put the 4 GiB wasm32 ceiling back in play
        /// for no benefit. The host already holds the bytes; it can pass them to
        /// the model directly.
        ///
        /// Requires a model with vision capability. Empty for ordinary
        /// text-only inference, which is why it is `#[serde(default)]` — a block
        /// compiled before this field existed still deserializes.
        #[serde(default)]
        images: Vec<Handle>,
    },
    /// Open a file. Capability-checked against the job's spec.
    ///
    /// Yields a handle and a length rather than contents — see the crate docs on
    /// why bulk data does not cross this boundary.
    Open {
        /// Path to open. Denied unless the spec grants read access to it.
        path: String,
    },
    /// Pull one bounded window of an open file into guest memory.
    ///
    /// The guest picks `len`, so the guest sets its own memory ceiling.
    Slice {
        /// Handle from a previous [`Command::Open`].
        handle: Handle,
        /// Byte offset to read from. `u64` so that files far larger than a guest
        /// could hold remain fully addressable.
        offset: u64,
        /// Maximum bytes to return. The host may return fewer; see
        /// [`Event::Sliced`].
        len: u64,
    },
    /// Pull one bounded window of an open file as raw bytes.
    ///
    /// The binary counterpart to [`Command::Slice`]. Prefer `Slice` for text:
    /// it needs no encoding, and it handles the character-boundary problem for
    /// you. This exists for blocks that genuinely need bytes — inspecting an
    /// image header, say — and pays base64's cost to carry them.
    SliceBytes {
        /// Handle from a previous [`Command::Open`].
        handle: Handle,
        /// Byte offset to read from.
        offset: u64,
        /// Maximum bytes to return.
        len: u64,
    },
    /// Extract one page of a document as text.
    ///
    /// Fails when the document has no text layer; check
    /// [`MediaKind::Document::has_text_layer`] first.
    PageText {
        /// Handle from a previous [`Command::Open`].
        handle: Handle,
        /// Zero-based page number.
        page: u32,
    },
    /// Render one page of a document to an image.
    ///
    /// Yields a *new* handle referring to the rendered image, which can then be
    /// named in [`Command::Infer`]. That indirection is deliberate: the image
    /// stays host-side like every other bulk value, and a rendered page is
    /// usable exactly wherever a file-backed image is.
    PageImage {
        /// Handle from a previous [`Command::Open`].
        handle: Handle,
        /// Zero-based page number.
        page: u32,
    },
    /// Report progress to whoever is watching the job's event stream.
    Emit {
        /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
        progress: serde_json::Value,
    },
    /// Finish successfully with this payload.
    Done {
        /// The job's result, shaped by the spec's declared output.
        result: serde_json::Value,
    },
    /// Give up. The job ends with this code and message, and no result.
    Fail {
        /// Machine-readable code; see [`error_codes`].
        code: String,
        /// Human-readable explanation.
        message: String,
    },
}

/// What the host feeds back into the guest's `step` export after carrying out a
/// [`Command`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
    /// Generation finished.
    InferDone {
        /// The generated text.
        text: String,
        /// How many tokens were produced. May be fewer than the requested
        /// `max_tokens` if the guest ended generation early.
        tokens_out: u32,
    },
    /// A file was opened.
    Opened {
        /// Use this in subsequent [`Command::Slice`] calls.
        handle: Handle,
        /// Total size of the file, in bytes.
        len: u64,
        /// What the host made of the contents; see [`MediaKind`].
        ///
        /// `#[serde(default)]` so a block built before this field existed still
        /// deserializes, treating anything it opens as text.
        #[serde(default)]
        kind: MediaKind,
    },
    /// A window of a file was read.
    Sliced {
        /// The window's contents.
        text: String,
        /// Where the returned text actually ended.
        ///
        /// This is **not** always `offset + len` from the request: the host cuts
        /// a window back to a UTF-8 character boundary, because a caller picking
        /// window sizes has no idea where characters begin, and a naive split
        /// would corrupt a multi-byte character at nearly every seam. A guest
        /// walking a file must resume from this value rather than advancing by
        /// the length it asked for.
        next_offset: u64,
    },
    /// A window of a file was read as raw bytes.
    SlicedBytes {
        /// The window's contents, base64-encoded.
        ///
        /// Base64 rather than a binary side channel: the boundary is JSON, and
        /// keeping it inspectable is worth more than the third it costs on a
        /// path blocks are not expected to use in bulk.
        bytes_base64: String,
        /// Where the returned bytes ended. Unlike [`Event::Sliced`] there is no
        /// truncation, so this is always `offset + len` clamped to the file.
        next_offset: u64,
    },
    /// A document page was extracted as text.
    PageTexted {
        /// The page's text.
        text: String,
    },
    /// A document page was rendered to an image.
    PageImaged {
        /// A new handle referring to the rendered image; name it in
        /// [`Command::Infer`].
        handle: Handle,
        /// Its size in bytes.
        len: u64,
    },
    /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
    /// and the command loop keeps its shape.
    Emitted,
}

/// A guest's verdict on each streamed token, returned from its `on_token`
/// export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenAction {
    /// Keep generating.
    Continue,
    /// Stop generating now.
    ///
    /// A token or two may still arrive after this, because the verdict has to
    /// travel back to the thread doing the generating.
    Stop,
}

impl TokenAction {
    /// Decode the raw `i32` a guest's `on_token` export returns.
    ///
    /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
    /// returning a value this crate does not recognise is malfunctioning, and
    /// the safe reading of a malfunctioning guest is "stop", never "keep
    /// spending tokens" — the same fail-closed posture as the capability checks.
    pub fn from_i32(v: i32) -> Self {
        if v == 0 {
            Self::Continue
        } else {
            Self::Stop
        }
    }

    /// Encode for the wasm boundary.
    ///
    /// These integers are part of the ABI: renumbering them silently changes the
    /// meaning of every already-compiled block.
    pub fn as_i32(self) -> i32 {
        match self {
            Self::Continue => 0,
            Self::Stop => 1,
        }
    }
}

/// Where a job is in its lifecycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobStatus {
    /// Accepted, not yet started.
    Queued,
    /// Executing.
    Running,
    /// Finished with a result.
    Completed,
    /// Finished with an error and no result.
    Failed,
    /// Stopped by request.
    Cancelled,
}

impl JobStatus {
    /// Whether this status is final — nothing further will happen to the job.
    ///
    /// Clients poll until this is true. Adding a new non-terminal status is
    /// therefore safe, while a new terminal one that is missing from this match
    /// leaves callers waiting forever.
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
    }
}

/// What a job cost.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Usage {
    /// Tokens consumed by prompts.
    pub tokens_in: u32,
    /// Tokens generated.
    pub tokens_out: u32,
    /// Wall-clock duration of the job.
    pub duration_ms: u64,
    /// Which model served the job's inference.
    pub model: String,
}

/// The fixed, spec-independent envelope handed back to the calling agent.
///
/// Every job returns this shape regardless of what it did, so an agent can
/// handle results without knowing anything about the block that produced them.
/// Only `result` varies, and its shape is that job's business.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Envelope {
    /// Lifecycle state; see [`JobStatus::is_terminal`].
    pub status: JobStatus,
    /// Present only when the job completed.
    ///
    /// A failed or cancelled job never carries a partial result: a caller must
    /// never have to guess whether a payload is trustworthy.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<serde_json::Value>,
    /// Present only when the job failed or was cancelled.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<JobError>,
    /// Cost accounting, populated even for failed jobs — work already spent
    /// still counts.
    pub usage: Usage,
}

/// Why a job did not complete.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JobError {
    /// Machine-readable; see [`error_codes`].
    pub code: String,
    /// Human-readable detail.
    pub message: String,
}

/// The error codes the daemon emits in [`JobError::code`].
///
/// These are string constants rather than an enum so the set can grow without
/// breaking clients that match on strings, and so a client built against an
/// older version meets an unfamiliar code rather than a decode failure.
pub mod error_codes {
    /// The job's model could not be loaded or served.
    pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
    /// A guest tried to reach something its spec does not grant.
    pub const CAPABILITY_DENIED: &str = "capability_denied";
    /// Job input did not match the spec's declared shape.
    pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
    /// The guest trapped — a panic, a bad export signature, or malformed wasm.
    pub const WASM_TRAP: &str = "wasm_trap";
    /// The job exceeded its time budget.
    pub const TIMEOUT: &str = "timeout";
    /// The job was cancelled by request.
    pub const CANCELLED: &str = "cancelled";
    /// A command needed a capability this build does not have — asking for a
    /// page image without document rendering compiled in, say.
    pub const UNSUPPORTED: &str = "unsupported";
}

impl Default for MediaKind {
    /// Text, because that is what every command predating [`MediaKind`]
    /// assumed, and because it keeps an older block's behaviour unchanged.
    fn default() -> Self {
        Self::Text
    }
}