Skip to main content

cuttlefish_abi/
lib.rs

1//! The contract between the cuttlefish host and its guest proc-blocks.
2//!
3//! Both sides depend on this crate precisely so that they cannot drift: a block
4//! is compiled separately from the host, often at a different time by a
5//! different person, and the only thing keeping them able to talk is that they
6//! agreed on these types.
7//!
8//! # Why a command loop, not function calls
9//!
10//! A block does not call the host. It *returns* a [`Command`] describing what it
11//! wants done, and the host — after doing it — hands back an [`Event`] and asks
12//! for the next command. Control is inverted relative to the obvious design, and
13//! not for taste:
14//!
15//! - A core-wasm guest is single-threaded and offers no execution context the
16//!   host could call back into while the guest is blocked. A "call the host and
17//!   wait" design has nowhere to deliver the answer.
18//! - Inference must run on a different thread from the wasm store, which is
19//!   `!Sync` and cannot be touched from there.
20//! - Because the host decides whether to take the next step, cancellation needs
21//!   no cooperation from the guest at all: the host simply stops stepping. A
22//!   guest cannot ignore, delay, or trap its way out of being cancelled.
23//!
24//! Everything crosses the boundary as JSON. That is slower than a packed binary
25//! layout, deliberately: the boundary stays inspectable, a mismatch produces a
26//! legible error rather than a misread integer, and the volume is low because
27//! bulk data does not cross it. Revisit only if profiling says to.
28//!
29//! # Why bulk data does not cross this boundary
30//!
31//! No command hands a block the contents of a file. A block [`Command::Open`]s a
32//! path, receives a [`Handle`] and a length, then pulls bounded windows with
33//! [`Command::Slice`].
34//!
35//! This keeps guest memory proportional to the window a block chooses rather
36//! than to the size of its input. A block written against a small file behaves
37//! identically against a huge one, and the 4 GiB ceiling of 32-bit wasm stops
38//! being something block authors must reason about — which is what lets this
39//! project stay on `wasm32` instead of paying for `wasm64`.
40
41#![forbid(unsafe_code)]
42#![warn(missing_docs)]
43
44use serde::{Deserialize, Serialize};
45
46/// A job-scoped reference to something the host holds open for a guest.
47///
48/// Job-scoping is a security property, not bookkeeping. A handle table lives and
49/// dies with a single job, so a handle from one job names nothing in another.
50/// That is why [`Command::Slice`] carries no path and needs no capability check
51/// of its own: the check happened once, at [`Command::Open`], and a handle
52/// cannot be forged into a reference to another job's data.
53pub type Handle = u32;
54
55/// The shape of a value flowing through a pipeline.
56///
57/// Deliberately small. This exists to catch the mistake that actually happens
58/// when blocks are composed — one block emitting a summary string into another
59/// expecting a list of chunks — not to be a general-purpose type system. A
60/// richer one would need inference, and inference over a language with no
61/// expressions is machinery without a use.
62/// Written and read as a compact string — `text`, `[text]`, `{path: text}` —
63/// rather than as a nested tagged object.
64///
65/// Two reasons, and the second is the one that forced it. It reads well in an
66/// error message and in a spec, so one syntax serves the wire, the diagnostics,
67/// and the DSL. And a recursive enum serialized structurally makes serde's
68/// generic serializer recurse deeply enough to blow rustc's recursion limit in
69/// the *guest* crate — which would have meant every block author adding
70/// `#![recursion_limit]` to work around a detail of this type.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Ty {
73    /// A UTF-8 string.
74    Text,
75    /// A JSON number, integral or fractional.
76    ///
77    /// One variant rather than an int/float pair: JSON itself does not
78    /// distinguish them, so a block returning `3` where `3.0` was meant would
79    /// fail a check that exists only in the type system and nowhere in the
80    /// data. Where the distinction matters downstream — a Parquet column type,
81    /// say — it is decided by looking at the values, which is the only place
82    /// the information actually exists.
83    Number,
84    /// A JSON boolean.
85    Bool,
86    /// Opaque bytes, base64-encoded on the wire.
87    Bytes,
88    /// A handle naming an image the host holds.
89    Image,
90    /// A handle naming a paged document.
91    Document,
92    /// Any JSON value. The top type: everything is assignable to it.
93    ///
94    /// An escape hatch, and one worth using sparingly — a pipeline of `Json`
95    /// seams typechecks unconditionally, which is the same as not checking.
96    Json,
97    /// An ordered sequence.
98    List(Box<Ty>),
99    /// A fixed set of named fields.
100    ///
101    /// A `BTreeMap` so that two records written in different field orders are
102    /// the same type, and so error messages list fields the same way twice.
103    Record(std::collections::BTreeMap<String, Ty>),
104}
105
106impl Ty {
107    /// Whether a value of this type can be fed where `expected` is required.
108    ///
109    /// Not equality: [`Ty::Json`] accepts anything, and a record with *extra*
110    /// fields satisfies one that needs fewer. Both directions of that matter —
111    /// a block that adds a field should not break its consumer, and a block
112    /// that requires a field its producer never emits should fail loudly.
113    pub fn assignable_to(&self, expected: &Ty) -> bool {
114        match (self, expected) {
115            (_, Ty::Json) => true,
116            (Ty::List(a), Ty::List(b)) => a.assignable_to(b),
117            (Ty::Record(have), Ty::Record(need)) => need
118                .iter()
119                .all(|(name, want)| have.get(name).is_some_and(|got| got.assignable_to(want))),
120            (a, b) => a == b,
121        }
122    }
123
124    /// Whether a live JSON value could plausibly be an instance of this
125    /// type — a runtime counterpart to [`Self::assignable_to`], which only
126    /// ever compares two declared `Ty`s against each other. Nothing in the
127    /// host checked a block's *actual* output against what it declared
128    /// until this existed: a block could claim `{summary: text}` and return
129    /// `{text: "..."}` and nothing downstream would notice until whatever
130    /// consumed `summary` got `null`.
131    ///
132    /// Deliberately permissive, not a full validator: [`Ty::Bytes`],
133    /// [`Ty::Image`], and [`Ty::Document`] have no fixed JSON shape defined
134    /// anywhere in this protocol (unlike [`Ty::Text`]/[`Ty::List`]/
135    /// [`Ty::Record`], which map onto JSON strings/arrays/objects
136    /// unambiguously) — inventing a shape for them here risks rejecting
137    /// legitimate values a real block already produces. Those three, like
138    /// [`Ty::Json`], accept anything.
139    pub fn matches_value(&self, value: &serde_json::Value) -> bool {
140        match self {
141            Ty::Json | Ty::Bytes | Ty::Image | Ty::Document => true,
142            Ty::Text => value.is_string(),
143            Ty::Number => value.is_number(),
144            Ty::Bool => value.is_boolean(),
145            Ty::List(inner) => value
146                .as_array()
147                .is_some_and(|items| items.iter().all(|v| inner.matches_value(v))),
148            Ty::Record(fields) => value.as_object().is_some_and(|obj| {
149                fields
150                    .iter()
151                    .all(|(name, want)| obj.get(name).is_some_and(|v| want.matches_value(v)))
152            }),
153        }
154    }
155
156    /// A short human-readable rendering, for error messages.
157    pub fn describe(&self) -> String {
158        match self {
159            Ty::Text => "text".into(),
160            Ty::Number => "number".into(),
161            Ty::Bool => "bool".into(),
162            Ty::Bytes => "bytes".into(),
163            Ty::Image => "image".into(),
164            Ty::Document => "document".into(),
165            Ty::Json => "json".into(),
166            Ty::List(inner) => format!("[{}]", inner.describe()),
167            Ty::Record(fields) => {
168                let body = fields
169                    .iter()
170                    .map(|(k, v)| format!("{k}: {}", v.describe()))
171                    .collect::<Vec<_>>()
172                    .join(", ");
173                format!("{{{body}}}")
174            }
175        }
176    }
177}
178
179impl std::fmt::Display for Ty {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        f.write_str(&self.describe())
182    }
183}
184
185impl std::str::FromStr for Ty {
186    type Err = String;
187
188    fn from_str(s: &str) -> Result<Self, Self::Err> {
189        parse_ty(s.trim())
190    }
191}
192
193impl Serialize for Ty {
194    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
195        s.serialize_str(&self.describe())
196    }
197}
198
199impl<'de> Deserialize<'de> for Ty {
200    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
201        let raw = String::deserialize(d)?;
202        raw.parse().map_err(serde::de::Error::custom)
203    }
204}
205
206/// Parse the type syntax [`Ty::describe`] produces.
207fn parse_ty(s: &str) -> Result<Ty, String> {
208    let s = s.trim();
209    match s {
210        "text" => return Ok(Ty::Text),
211        "number" => return Ok(Ty::Number),
212        "bool" => return Ok(Ty::Bool),
213        "bytes" => return Ok(Ty::Bytes),
214        "image" => return Ok(Ty::Image),
215        "document" => return Ok(Ty::Document),
216        "json" => return Ok(Ty::Json),
217        _ => {}
218    }
219
220    if let Some(inner) = s.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
221        return Ok(Ty::List(Box::new(parse_ty(inner)?)));
222    }
223
224    if let Some(body) = s.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
225        let mut fields = std::collections::BTreeMap::new();
226        if !body.trim().is_empty() {
227            for part in split_fields(body) {
228                let (name, ty) = part
229                    .split_once(':')
230                    .ok_or_else(|| format!("expected `name: type` in `{part}`"))?;
231                fields.insert(name.trim().to_string(), parse_ty(ty)?);
232            }
233        }
234        return Ok(Ty::Record(fields));
235    }
236
237    Err(format!("`{s}` is not a type"))
238}
239
240/// Split record fields on commas that are not inside a nested `[]` or `{}`.
241///
242/// A plain `split(',')` would cut `{a: [x, y]}` in the wrong place.
243fn split_fields(body: &str) -> Vec<String> {
244    let (mut out, mut depth, mut current) = (Vec::new(), 0i32, String::new());
245    for c in body.chars() {
246        match c {
247            '[' | '{' => {
248                depth += 1;
249                current.push(c);
250            }
251            ']' | '}' => {
252                depth -= 1;
253                current.push(c);
254            }
255            ',' if depth == 0 => out.push(std::mem::take(&mut current)),
256            _ => current.push(c),
257        }
258    }
259    if !current.trim().is_empty() {
260        out.push(current);
261    }
262    out
263}
264
265/// What a block accepts and produces.
266///
267/// Declared by the block itself, through a `cf_signature` export, rather than in
268/// a sidecar file beside it. A sidecar can disagree with the code it describes
269/// and nothing forces anyone to notice; a declaration compiled into the module
270/// travels with it, cannot go stale, and leaves one artifact to ship rather than
271/// two to keep in step.
272///
273/// `Display`/`FromStr` render and parse it as `"{input} -> {output}"` —
274/// each side is a [`Ty`], and this is the compact form the catalog caches
275/// and a bundle manifest embeds. Splitting on `" -> "` is unambiguous only
276/// because `Ty::describe()` never produces that substring itself.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct Signature {
279    /// What the block needs as input.
280    pub input: Ty,
281    /// What it produces.
282    pub output: Ty,
283}
284
285impl std::fmt::Display for Signature {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        write!(f, "{} -> {}", self.input, self.output)
288    }
289}
290
291impl std::str::FromStr for Signature {
292    type Err = String;
293
294    fn from_str(s: &str) -> Result<Self, Self::Err> {
295        let (input, output) = s
296            .split_once(" -> ")
297            .ok_or_else(|| format!("`{s}` is not a signature (expected `input -> output`)"))?;
298        Ok(Signature {
299            input: input.parse()?,
300            output: output.parse()?,
301        })
302    }
303}
304
305/// What kind of thing a handle refers to, reported by [`Event::Opened`].
306///
307/// A block needs this to know which commands are worth issuing: [`Command::Slice`]
308/// on a PNG is a mistake, and [`Command::PageText`] on a plain text file is
309/// meaningless. Reporting it up front means a block can branch on what it
310/// actually got rather than guessing from a file extension.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(tag = "kind", rename_all = "snake_case")]
313pub enum MediaKind {
314    /// Valid UTF-8. Both [`Command::Slice`] and [`Command::SliceBytes`] work.
315    Text,
316    /// An image the host recognised. Usable as an [`Command::Infer`] image.
317    Image {
318        /// Format as detected from content, e.g. `png`, `jpeg`.
319        format: String,
320    },
321    /// A paged document — a PDF, say.
322    Document {
323        /// How many pages it has.
324        pages: u32,
325        /// Whether it carries an extractable text layer.
326        ///
327        /// False for a scanned document, where the only way to read it is to
328        /// rasterize pages and hand them to a vision model. A block that checks
329        /// this can pick the cheap path when it exists and the expensive one
330        /// when it must, instead of silently extracting nothing.
331        has_text_layer: bool,
332    },
333    /// Bytes the host could not classify. Only [`Command::SliceBytes`] applies.
334    Binary,
335}
336
337/// One transformation [`Command::ImageOp`] can apply.
338///
339/// Deliberately a closed set rather than a general filter language: each of
340/// these answers a question an analysis pipeline actually asks, and a closed
341/// set is one a host can implement completely and a block can rely on.
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
343#[serde(tag = "op", rename_all = "snake_case")]
344pub enum ImageOperation {
345    /// Scale to fit within these bounds, preserving aspect ratio.
346    ///
347    /// The common case before handing an image to a vision model, which
348    /// resamples to a fixed size anyway — sending a 48-megapixel original
349    /// costs encode time and tokens for detail the model cannot use.
350    Resize {
351        /// Maximum width in pixels.
352        max_width: u32,
353        /// Maximum height in pixels.
354        max_height: u32,
355    },
356    /// Cut out a region, for asking about part of an image rather than all
357    /// of it.
358    Crop {
359        /// Left edge, in pixels from the origin.
360        x: u32,
361        /// Top edge, in pixels from the origin.
362        y: u32,
363        /// Region width.
364        width: u32,
365        /// Region height.
366        height: u32,
367    },
368}
369
370/// What a guest asks the host to do, returned from its `init`/`step` exports.
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372#[serde(tag = "cmd", rename_all = "snake_case")]
373pub enum Command {
374    /// Run a prompt against the job's model.
375    Infer {
376        /// The prompt to generate from.
377        prompt: String,
378        /// Upper bound on tokens generated. A guest can also end generation
379        /// early by returning [`TokenAction::Stop`] from its `on_token` export.
380        max_tokens: u32,
381        /// Images to accompany the prompt, named by handle.
382        ///
383        /// Handles rather than bytes, for the same reason file contents are not
384        /// handed over: an image can be tens of megabytes, and routing it
385        /// through guest memory would put the 4 GiB wasm32 ceiling back in play
386        /// for no benefit. The host already holds the bytes; it can pass them to
387        /// the model directly.
388        ///
389        /// Requires a model with vision capability. Empty for ordinary
390        /// text-only inference, which is why it is `#[serde(default)]` — a block
391        /// compiled before this field existed still deserializes.
392        #[serde(default)]
393        images: Vec<Handle>,
394    },
395    /// Open a file. Capability-checked against the job's spec.
396    ///
397    /// Yields a handle and a length rather than contents — see the crate docs on
398    /// why bulk data does not cross this boundary.
399    Open {
400        /// Path to open. Denied unless the spec grants read access to it.
401        path: String,
402    },
403    /// Pull one bounded window of an open file into guest memory.
404    ///
405    /// The guest picks `len`, so the guest sets its own memory ceiling.
406    Slice {
407        /// Handle from a previous [`Command::Open`].
408        handle: Handle,
409        /// Byte offset to read from. `u64` so that files far larger than a guest
410        /// could hold remain fully addressable.
411        offset: u64,
412        /// Maximum bytes to return. The host may return fewer; see
413        /// [`Event::Sliced`].
414        len: u64,
415    },
416    /// Pull one bounded window of an open file as raw bytes.
417    ///
418    /// The binary counterpart to [`Command::Slice`]. Prefer `Slice` for text:
419    /// it needs no encoding, and it handles the character-boundary problem for
420    /// you. This exists for blocks that genuinely need bytes — inspecting an
421    /// image header, say — and pays base64's cost to carry them.
422    SliceBytes {
423        /// Handle from a previous [`Command::Open`].
424        handle: Handle,
425        /// Byte offset to read from.
426        offset: u64,
427        /// Maximum bytes to return.
428        len: u64,
429    },
430    /// Extract one page of a document as text.
431    ///
432    /// Fails when the document has no text layer; check
433    /// [`MediaKind::Document::has_text_layer`] first.
434    PageText {
435        /// Handle from a previous [`Command::Open`].
436        handle: Handle,
437        /// Zero-based page number.
438        page: u32,
439    },
440    /// Render one page of a document to an image.
441    ///
442    /// Yields a *new* handle referring to the rendered image, which can then be
443    /// named in [`Command::Infer`]. That indirection is deliberate: the image
444    /// stays host-side like every other bulk value, and a rendered page is
445    /// usable exactly wherever a file-backed image is.
446    PageImage {
447        /// Handle from a previous [`Command::Open`].
448        handle: Handle,
449        /// Zero-based page number.
450        page: u32,
451    },
452    /// Embed one or more texts, returning a vector each.
453    ///
454    /// Batched by construction: the guest hands over every text it wants
455    /// embedded in one command. Embedding a corpus means tens of thousands
456    /// of chunks, and a round trip per chunk is the difference between
457    /// minutes and hours — so the batch is the primitive and a single text
458    /// is just a batch of one.
459    ///
460    /// Served by the spec's `embedding_model`, which is deliberately not the
461    /// job's chat model: they are different models, and asking a chat model
462    /// to embed either fails or returns something that is not an embedding.
463    Embed {
464        /// The texts to embed, in order. Vectors come back in the same order.
465        texts: Vec<String>,
466    },
467    /// Download a URL and hand back a handle to it.
468    ///
469    /// Answers with [`Event::Opened`], the same event [`Command::Open`]
470    /// produces, so everything downstream is unchanged: a fetched resource
471    /// can be sliced, identified, extracted from, or handed to a vision
472    /// model exactly as a local file can. One new command, no new surface.
473    ///
474    /// Requires a matching `Fetch` prefix in the spec's `capabilities`. A
475    /// corpus on the web is still a corpus, and the capability list has to
476    /// describe reaching it.
477    Fetch {
478        /// The URL to retrieve. Must begin with a granted prefix.
479        url: String,
480    },
481    /// Every character of text in a document, in one call.
482    ///
483    /// What most callers reading a PDF actually want, and the direct way to
484    /// ask for it. Before this existed the only route was
485    /// [`Command::PageText`] with `page: 0`, which reads like "the first
486    /// page" and quietly means "everything" whenever the extractor emitted
487    /// no page breaks — a gap that cost a real user two iterations and
488    /// nearly a wrong conclusion about whether cuttlefish could read their
489    /// corpus at all.
490    ///
491    /// Prefer this over walking pages unless the pages are genuinely needed
492    /// separately: the whole document is one extraction either way.
493    DocumentText {
494        /// Handle from a previous [`Command::Open`].
495        handle: Handle,
496    },
497    /// Transform an image handle, yielding a *new* image handle.
498    ///
499    /// Same indirection as [`Command::PageImage`], and for the same reason:
500    /// pixels stay host-side, and the result is usable exactly wherever a
501    /// file-backed image is — including as an [`Command::Infer`] image.
502    ///
503    /// This is the one image operation that needs a decoder, which is why it
504    /// lives here rather than in the guest. Metadata (dimensions, EXIF,
505    /// chunk structure) is header parsing and is available to any script
506    /// without a host round trip or a feature flag; *looking at the pixels*
507    /// is not.
508    ///
509    /// Hosts built without the `image-ops` feature reject this with a clear
510    /// message rather than pretending to succeed.
511    ImageOp {
512        /// Handle from a previous [`Command::Open`] or [`Command::PageImage`].
513        handle: Handle,
514        /// What to do to it.
515        op: ImageOperation,
516    },
517    /// Report progress to whoever is watching the job's event stream.
518    Emit {
519        /// Arbitrary JSON, forwarded verbatim to the job's subscribers.
520        progress: serde_json::Value,
521    },
522    /// Finish successfully with this payload.
523    Done {
524        /// The job's result, shaped by the spec's declared output.
525        result: serde_json::Value,
526    },
527    /// Give up. The job ends with this code and message, and no result.
528    Fail {
529        /// Machine-readable code; see [`error_codes`].
530        code: String,
531        /// Human-readable explanation.
532        message: String,
533    },
534}
535
536/// What the host feeds back into the guest's `step` export after carrying out a
537/// [`Command`].
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
539#[serde(tag = "event", rename_all = "snake_case")]
540pub enum Event {
541    /// Generation finished.
542    InferDone {
543        /// The generated text.
544        text: String,
545        /// How many tokens were produced. May be fewer than the requested
546        /// `max_tokens` if the guest ended generation early.
547        tokens_out: u32,
548    },
549    /// A file was opened.
550    Opened {
551        /// Use this in subsequent [`Command::Slice`] calls.
552        handle: Handle,
553        /// Total size of the file, in bytes.
554        len: u64,
555        /// What the host made of the contents; see [`MediaKind`].
556        ///
557        /// `#[serde(default)]` so a block built before this field existed still
558        /// deserializes, treating anything it opens as text.
559        #[serde(default)]
560        kind: MediaKind,
561    },
562    /// A window of a file was read.
563    Sliced {
564        /// The window's contents.
565        text: String,
566        /// Where the returned text actually ended.
567        ///
568        /// This is **not** always `offset + len` from the request: the host cuts
569        /// a window back to a UTF-8 character boundary, because a caller picking
570        /// window sizes has no idea where characters begin, and a naive split
571        /// would corrupt a multi-byte character at nearly every seam. A guest
572        /// walking a file must resume from this value rather than advancing by
573        /// the length it asked for.
574        next_offset: u64,
575    },
576    /// A window of a file was read as raw bytes.
577    SlicedBytes {
578        /// The window's contents, base64-encoded.
579        ///
580        /// Base64 rather than a binary side channel: the boundary is JSON, and
581        /// keeping it inspectable is worth more than the third it costs on a
582        /// path blocks are not expected to use in bulk.
583        bytes_base64: String,
584        /// Where the returned bytes ended. Unlike [`Event::Sliced`] there is no
585        /// truncation, so this is always `offset + len` clamped to the file.
586        next_offset: u64,
587    },
588    /// One vector per text handed to [`Command::Embed`], in the same order.
589    Embedded {
590        /// The vectors, each the model's full dimensionality.
591        vectors: Vec<Vec<f32>>,
592    },
593    /// Text extracted from a document — one page, or the whole thing.
594    ///
595    /// Shared by [`Command::PageText`] and [`Command::DocumentText`]: both
596    /// answer with text and nothing else, so a second variant carrying an
597    /// identical payload would add surface without adding information. The
598    /// *command* is what names the intent.
599    PageTexted {
600        /// The page's text.
601        text: String,
602    },
603    /// A document page was rendered to an image.
604    PageImaged {
605        /// A new handle referring to the rendered image; name it in
606        /// [`Command::Infer`].
607        handle: Handle,
608        /// Its size in bytes.
609        len: u64,
610    },
611    /// Progress was forwarded. Carries nothing; it exists so `Emit` has a reply
612    /// and the command loop keeps its shape.
613    Emitted,
614}
615
616/// A guest's verdict on each streamed token, returned from its `on_token`
617/// export.
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub enum TokenAction {
620    /// Keep generating.
621    Continue,
622    /// Stop generating now.
623    ///
624    /// A token or two may still arrive after this, because the verdict has to
625    /// travel back to the thread doing the generating.
626    Stop,
627}
628
629impl TokenAction {
630    /// Decode the raw `i32` a guest's `on_token` export returns.
631    ///
632    /// Anything that is not an explicit `Continue` reads as `Stop`. A guest
633    /// returning a value this crate does not recognise is malfunctioning, and
634    /// the safe reading of a malfunctioning guest is "stop", never "keep
635    /// spending tokens" — the same fail-closed posture as the capability checks.
636    pub fn from_i32(v: i32) -> Self {
637        if v == 0 {
638            Self::Continue
639        } else {
640            Self::Stop
641        }
642    }
643
644    /// Encode for the wasm boundary.
645    ///
646    /// These integers are part of the ABI: renumbering them silently changes the
647    /// meaning of every already-compiled block.
648    pub fn as_i32(self) -> i32 {
649        match self {
650            Self::Continue => 0,
651            Self::Stop => 1,
652        }
653    }
654}
655
656/// Where a job is in its lifecycle.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum JobStatus {
660    /// Accepted, not yet started.
661    Queued,
662    /// Executing.
663    Running,
664    /// Finished with a result.
665    Completed,
666    /// Finished with an error and no result.
667    Failed,
668    /// Stopped by request.
669    Cancelled,
670    /// Was running when the daemon last stopped; not resumed automatically.
671    Interrupted,
672}
673
674impl JobStatus {
675    /// Whether this status is final — nothing further will happen to the job.
676    ///
677    /// Clients poll until this is true. Adding a new non-terminal status is
678    /// therefore safe, while a new terminal one that is missing from this match
679    /// leaves callers waiting forever.
680    pub fn is_terminal(self) -> bool {
681        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
682    }
683}
684
685/// What a job cost.
686#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
687pub struct Usage {
688    /// Tokens consumed by prompts.
689    pub tokens_in: u32,
690    /// Tokens generated.
691    pub tokens_out: u32,
692    /// Wall-clock duration of the job.
693    pub duration_ms: u64,
694    /// Which model served the job's inference.
695    pub model: String,
696}
697
698/// The fixed, spec-independent envelope handed back to the calling agent.
699///
700/// Every job returns this shape regardless of what it did, so an agent can
701/// handle results without knowing anything about the block that produced them.
702/// Only `result` varies, and its shape is that job's business.
703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
704pub struct Envelope {
705    /// Lifecycle state; see [`JobStatus::is_terminal`].
706    pub status: JobStatus,
707    /// Present only when the job completed.
708    ///
709    /// A failed or cancelled job never carries a partial result: a caller must
710    /// never have to guess whether a payload is trustworthy.
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub result: Option<serde_json::Value>,
713    /// Present only when the job failed or was cancelled.
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub error: Option<JobError>,
716    /// Cost accounting, populated even for failed jobs — work already spent
717    /// still counts.
718    pub usage: Usage,
719}
720
721/// Why a job did not complete.
722#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
723pub struct JobError {
724    /// Machine-readable; see [`error_codes`].
725    pub code: String,
726    /// Human-readable detail.
727    pub message: String,
728}
729
730/// The error codes the daemon emits in [`JobError::code`].
731///
732/// These are string constants rather than an enum so the set can grow without
733/// breaking clients that match on strings, and so a client built against an
734/// older version meets an unfamiliar code rather than a decode failure.
735pub mod error_codes {
736    /// The job's model could not be loaded or served.
737    pub const MODEL_LOAD_FAILED: &str = "model_load_failed";
738    /// A guest tried to reach something its spec does not grant.
739    pub const CAPABILITY_DENIED: &str = "capability_denied";
740    /// Job input did not match the spec's declared shape.
741    pub const SCHEMA_VALIDATION_FAILED: &str = "schema_validation_failed";
742    /// A Script-kind block's own logic failed at run time — a Rhai runtime
743    /// error (e.g. an out-of-bounds index, a missing function) surfacing
744    /// from inside the script itself. `catalog add` rejects a script whose
745    /// body doesn't *parse*, so this is specifically a failure that only
746    /// shows up once the script actually runs; it is not a schema mismatch,
747    /// so it does not reuse [`SCHEMA_VALIDATION_FAILED`].
748    pub const SCRIPT_ERROR: &str = "script_error";
749    /// The guest trapped — a panic, a bad export signature, or malformed wasm.
750    pub const WASM_TRAP: &str = "wasm_trap";
751    /// The job exceeded its time budget.
752    pub const TIMEOUT: &str = "timeout";
753    /// The job was cancelled by request.
754    pub const CANCELLED: &str = "cancelled";
755    /// A command needed a capability this build does not have — asking for a
756    /// page image without document rendering compiled in, say.
757    pub const UNSUPPORTED: &str = "unsupported";
758    /// A path the job may read names nothing.
759    ///
760    /// Distinct from [`CAPABILITY_DENIED`] on purpose: one is a wrong path
761    /// and the other a wrong grant, and they are fixed in different files.
762    /// Only used where the job could have discovered the absence itself —
763    /// outside a grant, absence and refusal stay indistinguishable, because
764    /// telling them apart is an existence oracle.
765    pub const NOT_FOUND: &str = "not_found";
766}
767
768impl Default for MediaKind {
769    /// Text, because that is what every command predating [`MediaKind`]
770    /// assumed, and because it keeps an older block's behaviour unchanged.
771    fn default() -> Self {
772        Self::Text
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn a_signature_round_trips_through_its_compact_string() {
782        let sig = Signature {
783            input: Ty::Record([("path".to_string(), Ty::Text)].into_iter().collect()),
784            output: Ty::List(Box::new(Ty::Text)),
785        };
786        let s = sig.to_string();
787        assert_eq!(s, "{path: text} -> [text]");
788        assert_eq!(s.parse::<Signature>().unwrap(), sig);
789    }
790
791    #[test]
792    fn a_signature_without_an_arrow_is_rejected() {
793        assert!("just-a-type".parse::<Signature>().is_err());
794    }
795
796    #[test]
797    fn a_signature_with_an_unparseable_side_is_rejected() {
798        assert!("text -> not a type".parse::<Signature>().is_err());
799    }
800
801    #[test]
802    fn text_matches_a_json_string_only() {
803        assert!(Ty::Text.matches_value(&serde_json::json!("hello")));
804        assert!(!Ty::Text.matches_value(&serde_json::json!(42)));
805    }
806
807    #[test]
808    fn json_matches_anything() {
809        assert!(Ty::Json.matches_value(&serde_json::json!(null)));
810        assert!(Ty::Json.matches_value(&serde_json::json!([1, "two", {}])));
811    }
812
813    #[test]
814    fn a_record_missing_a_declared_field_does_not_match() {
815        let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
816        assert!(!ty.matches_value(&serde_json::json!({"text": "hello world"})));
817    }
818
819    #[test]
820    fn a_record_with_the_declared_field_present_and_well_typed_matches() {
821        let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
822        assert!(ty.matches_value(&serde_json::json!({"summary": "hi", "extra": 1})));
823    }
824
825    #[test]
826    fn a_record_with_a_wrong_typed_declared_field_does_not_match() {
827        let ty = Ty::Record([("summary".to_string(), Ty::Text)].into_iter().collect());
828        assert!(!ty.matches_value(&serde_json::json!({"summary": 42})));
829    }
830
831    #[test]
832    fn a_list_matches_only_when_every_element_matches_the_inner_type() {
833        let ty = Ty::List(Box::new(Ty::Text));
834        assert!(ty.matches_value(&serde_json::json!(["a", "b"])));
835        assert!(!ty.matches_value(&serde_json::json!(["a", 2])));
836        assert!(!ty.matches_value(&serde_json::json!("not a list")));
837    }
838
839    #[test]
840    fn bytes_image_and_document_accept_anything() {
841        for ty in [Ty::Bytes, Ty::Image, Ty::Document] {
842            assert!(ty.matches_value(&serde_json::json!(123)));
843        }
844    }
845}