Skip to main content

cuttlefish_host/
runner.rs

1//! The reactor loop: the host drives the guest, one command at a time.
2//!
3//! This module is the shape of the whole system. The guest never calls the host
4//! and waits — it returns a [`Command`], the host carries it out, and the host
5//! steps the guest again with the resulting [`Event`]. Two things follow from
6//! that inversion, and both are why it is worth the awkwardness:
7//!
8//! - **Cancellation needs no guest cooperation.** The host simply stops
9//!   stepping. A guest cannot ignore, delay, or trap its way out of it.
10//! - **Every iteration is observable.** Progress, token counts, and capability
11//!   decisions all pass through the host, even for a block whose internal loop
12//!   the DAG cannot see.
13//!
14//! The alternative — host functions the guest imports and blocks on — is not
15//! merely less tidy, it does not work: a single-threaded core-wasm guest offers
16//! no execution context for the host to call back into, and the wasmtime `Store`
17//! is `!Sync` while inference must run on a separate thread.
18
19use crate::caps::Capabilities;
20use crate::handles::Handles;
21use crate::infer::{InferBackend, InferRequest, InferResult};
22use cuttlefish_abi::{error_codes, Command, Envelope, Event, JobError, JobStatus, Usage};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::mpsc;
27use tokio_util::sync::CancellationToken;
28use wasmtime::{Engine, Instance, Linker, Memory, Module, Store, TypedFunc};
29
30/// Width, in pixels, that document pages render to.
31///
32/// Vision models work from a fixed-size input anyway, and a larger raster costs
33/// encode time and tokens without adding detail the model can use.
34const RENDER_WIDTH: u16 = 1024;
35
36/// Something worth telling a watcher about while a job runs.
37#[derive(Debug, Clone)]
38pub enum JobEvent {
39    /// One generated token.
40    Token(String),
41    /// Guest-supplied progress.
42    Progress(serde_json::Value),
43}
44
45/// Everything needed to run one job.
46pub struct JobSpec {
47    /// The checked graph, in topological order — safe to execute
48    /// front-to-back, threading `outputs` forward.
49    pub nodes: Vec<crate::dag::CheckedNode>,
50    /// Which nodes are exclusive to which branch decision+label — see
51    /// `crate::dag::BranchExclusivity`.
52    pub exclusive_to: std::collections::HashMap<String, crate::dag::BranchExclusivity>,
53    /// The job's input, handed to every entry node (a node with no `input`
54    /// expression — `node.input.is_none()`).
55    pub input: serde_json::Value,
56    /// What this job is permitted to reach.
57    pub caps: Capabilities,
58}
59
60/// Pointer width of a guest module, read from the module rather than assumed.
61///
62/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
63/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
64/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
65/// file for every place a pointer was assumed to be four bytes wide.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Abi {
68    /// 32-bit linear memory.
69    W32,
70    /// 64-bit linear memory (memory64).
71    W64,
72}
73
74impl Abi {
75    /// Size of one pointer-sized field, and so half a descriptor.
76    fn ptr_size(self) -> usize {
77        match self {
78            Abi::W32 => 4,
79            Abi::W64 => 8,
80        }
81    }
82}
83
84struct Guest {
85    store: Store<()>,
86    memory: Memory,
87    abi: Abi,
88    alloc: TypedFunc<u32, u32>,
89    init: TypedFunc<(u32, u32), u32>,
90    step: TypedFunc<(u32, u32), u32>,
91    on_token: Option<TypedFunc<(u32, u32), i32>>,
92}
93
94impl Guest {
95    fn new(engine: &Engine, module_bytes: &[u8]) -> anyhow::Result<Self> {
96        let module = Module::new(engine, module_bytes)?;
97
98        // An empty linker, deliberately. Guest blocks are built for
99        // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
100        // would drag in `fd_write` and `proc_exit` through its panic path alone
101        // and fail to instantiate here.
102        let linker: Linker<()> = Linker::new(engine);
103        let mut store = Store::new(engine, ());
104        let instance: Instance = linker.instantiate(&mut store, &module)?;
105
106        let memory = instance
107            .get_memory(&mut store, "memory")
108            .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
109
110        // Width comes from the module itself. A 64-bit guest exports `cf_init`
111        // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
112        // fail with a signature mismatch that says nothing about the real cause.
113        let abi = if memory.ty(&store).is_64() {
114            Abi::W64
115        } else {
116            Abi::W32
117        };
118        if abi == Abi::W64 {
119            anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
120        }
121
122        Ok(Self {
123            alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
124            init: instance.get_typed_func(&mut store, "cf_init")?,
125            step: instance.get_typed_func(&mut store, "cf_step")?,
126            // Optional: a block indifferent to streaming need not export it.
127            on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
128            memory,
129            abi,
130            store,
131        })
132    }
133
134    fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
135        let len = bytes.len() as u32;
136        let ptr = self.alloc.call(&mut self.store, len)?;
137        self.memory.write(&mut self.store, ptr as usize, bytes)?;
138        Ok((ptr, len))
139    }
140
141    /// Read the descriptor the guest returned, then the payload it points at.
142    ///
143    /// Two reads rather than unpacking one integer — the cost of keeping these
144    /// signatures identical across pointer widths.
145    fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
146        let w = self.abi.ptr_size();
147        let mut desc = vec![0u8; 2 * w];
148        self.memory
149            .read(&mut self.store, desc_ptr as usize, &mut desc)?;
150
151        let field = |bytes: &[u8]| -> u64 {
152            match w {
153                4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
154                _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
155            }
156        };
157        let ptr = field(&desc[..w]) as usize;
158        let len = field(&desc[w..]) as usize;
159
160        let mut buf = vec![0u8; len];
161        self.memory.read(&mut self.store, ptr, &mut buf)?;
162        Ok(buf)
163    }
164
165    fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
166        let bytes = serde_json::to_vec(input)?;
167        let (ptr, len) = self.write(&bytes)?;
168        let desc = self.init.call(&mut self.store, (ptr, len))?;
169        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
170    }
171
172    fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
173        let bytes = serde_json::to_vec(event)?;
174        let (ptr, len) = self.write(&bytes)?;
175        let desc = self.step.call(&mut self.store, (ptr, len))?;
176        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
177    }
178
179    /// Ask the guest whether generation should continue.
180    fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
181        // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
182        // and cloning also ends the borrow of `self` before `write` needs it
183        // mutably.
184        let Some(f) = self.on_token.clone() else {
185            return Ok(true);
186        };
187        let (ptr, len) = self.write(token.as_bytes())?;
188        Ok(f.call(&mut self.store, (ptr, len))? == 0)
189    }
190}
191
192/// Read a block's declared signature out of a compiled module.
193///
194/// Instantiates the module and calls its `cf_signature` export. That is heavier
195/// than parsing a sidecar file, and it is the point: the answer comes from the
196/// artifact that will actually run, so it cannot describe a different version of
197/// the block than the one being checked.
198///
199/// A block built before signatures existed has no such export. That is not an
200/// error — it reports the permissive default, so an older block still composes,
201/// just without the seam being checked.
202pub fn read_signature(
203    engine: &Engine,
204    module_bytes: &[u8],
205) -> anyhow::Result<cuttlefish_abi::Signature> {
206    let permissive = cuttlefish_abi::Signature {
207        input: cuttlefish_abi::Ty::Json,
208        output: cuttlefish_abi::Ty::Json,
209    };
210
211    // Deliberately does not go through `Guest`, which requires the whole reactor
212    // — alloc, init, step. Reading a declaration should not demand that a module
213    // be runnable: a block missing an export has a real problem, but it is one
214    // worth reporting when the job runs, with the job's error handling, rather
215    // than as a confusing failure during a typecheck.
216    let module = Module::new(engine, module_bytes)?;
217    let linker: Linker<()> = Linker::new(engine);
218    let mut store = Store::new(engine, ());
219    let instance = linker.instantiate(&mut store, &module)?;
220
221    let Ok(signature) = instance.get_typed_func::<(), u32>(&mut store, "cf_signature") else {
222        return Ok(permissive);
223    };
224    let Some(memory) = instance.get_memory(&mut store, "memory") else {
225        return Ok(permissive);
226    };
227
228    let desc_ptr = signature.call(&mut store, ())? as usize;
229    let mut desc = [0u8; 8];
230    memory.read(&mut store, desc_ptr, &mut desc)?;
231    let ptr = u32::from_le_bytes(desc[..4].try_into().expect("4 bytes")) as usize;
232    let len = u32::from_le_bytes(desc[4..].try_into().expect("4 bytes")) as usize;
233
234    let mut buf = vec![0u8; len];
235    memory.read(&mut store, ptr, &mut buf)?;
236    Ok(serde_json::from_slice(&buf)?)
237}
238
239fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
240    Envelope {
241        status: JobStatus::Failed,
242        result: None,
243        error: Some(JobError {
244            code: code.into(),
245            message: message.into(),
246        }),
247        usage,
248    }
249}
250
251/// Whether an `InputExpr` (transitively) references any node in `skipped`.
252fn references_any(
253    expr: &cuttlefish_core::graph::InputExpr,
254    skipped: &std::collections::HashSet<String>,
255) -> bool {
256    use cuttlefish_core::graph::InputExpr;
257    match expr {
258        InputExpr::FromNode(n) => skipped.contains(n),
259        InputExpr::Record(fields) => fields.values().any(|e| references_any(e, skipped)),
260        InputExpr::List(items) => items.iter().any(|e| references_any(e, skipped)),
261    }
262}
263
264/// Compose an `InputExpr` into an actual JSON value, by looking up each
265/// referenced node's already-produced output. Mirrors `dag::evaluate_expr_ty`
266/// (which does the same composition at the *type* level, at check time) —
267/// this is the runtime analogue.
268fn evaluate_input(
269    expr: &cuttlefish_core::graph::InputExpr,
270    outputs: &std::collections::HashMap<String, serde_json::Value>,
271) -> serde_json::Value {
272    use cuttlefish_core::graph::InputExpr;
273    match expr {
274        InputExpr::FromNode(n) => outputs.get(n).cloned().unwrap_or(serde_json::Value::Null),
275        InputExpr::Record(fields) => {
276            let mut map = serde_json::Map::new();
277            for (k, v) in fields {
278                map.insert(k.clone(), evaluate_input(v, outputs));
279            }
280            serde_json::Value::Object(map)
281        }
282        InputExpr::List(items) => {
283            serde_json::Value::Array(items.iter().map(|e| evaluate_input(e, outputs)).collect())
284        }
285    }
286}
287
288fn cancelled(usage: Usage, message: &str) -> Envelope {
289    Envelope {
290        status: JobStatus::Cancelled,
291        result: None,
292        error: Some(JobError {
293            code: error_codes::CANCELLED.into(),
294            message: message.into(),
295        }),
296        usage,
297    }
298}
299
300/// Drive one job to completion.
301///
302/// Always returns an [`Envelope`]; failures are values, not errors, because the
303/// caller has to report *something* to whoever submitted the job.
304///
305/// `ledger` is consulted before any resume-sensitive decision (branch-skip,
306/// transitive-skip, or actually running a node) and written to immediately
307/// after that decision is made, so that a process restart mid-job — a later
308/// task's concern, not this function's — can resume from exactly the state
309/// this function left behind. The whole body runs inside a single labeled
310/// block (`'run: { ... }`) so that every exit path, however it got there,
311/// still reaches the one `ledger.finish(...)` call at the end.
312pub async fn run_job(
313    engine: Arc<Engine>,
314    backend: Arc<dyn InferBackend>,
315    job: JobSpec,
316    events: mpsc::Sender<JobEvent>,
317    cancel: CancellationToken,
318    ledger: &crate::ledger::Ledger,
319) -> Envelope {
320    let started = Instant::now();
321    let mut usage = Usage {
322        model: backend.model_name(),
323        ..Usage::default()
324    };
325
326    // Dropped when this function returns, closing every file the job opened.
327    // That job-scoped lifetime is what makes handles unforgeable across jobs.
328    //
329    // Shared across stages on purpose: a handle produced by one block — a
330    // rendered page, say — stays usable by the next. Confining it to one stage
331    // would make a pipeline strictly weaker than a single block that did the
332    // same work, while adding nothing, since the job boundary is what the
333    // security property rests on.
334    let mut handles = Handles::default();
335
336    let envelope = 'run: {
337        if job.nodes.is_empty() {
338            usage.duration_ms = started.elapsed().as_millis() as u64;
339            break 'run fail(
340                error_codes::SCHEMA_VALIDATION_FAILED,
341                "this job has no nodes to run",
342                usage,
343            );
344        }
345
346        let total = job.nodes.len();
347        let mut outputs: std::collections::HashMap<String, serde_json::Value> =
348            std::collections::HashMap::new();
349        let mut skipped: std::collections::HashSet<String> = std::collections::HashSet::new();
350        let mut route_taken: std::collections::HashMap<String, String> =
351            std::collections::HashMap::new();
352
353        for (index, node) in job.nodes.iter().enumerate() {
354            // Resume: a node the ledger already marked skipped stays skipped
355            // — its branch decision is not re-evaluated.
356            match ledger.is_skipped(&node.name) {
357                Ok(true) => {
358                    skipped.insert(node.name.clone());
359                    continue;
360                }
361                Ok(false) => {}
362                Err(e) => {
363                    usage.duration_ms = started.elapsed().as_millis() as u64;
364                    break 'run fail(
365                        error_codes::SCHEMA_VALIDATION_FAILED,
366                        format!("reading ledger skip state for node `{}`: {e}", node.name),
367                        usage,
368                    );
369                }
370            }
371
372            // Resume: a completed checkpoint means reuse the cached output
373            // instead of re-running.
374            match ledger.get_completed(&node.name) {
375                Ok(Some(cached)) => {
376                    // If this was a branches decision node, its route must be
377                    // recorded in `route_taken` here too — not just after a
378                    // fresh `run_stage` call below — or a downstream,
379                    // not-yet-reached branch-exclusive node would see no
380                    // recorded decision at all on a resumed run and
381                    // (incorrectly) never be skipped. The checkpoint only
382                    // ever holds a value that already passed this same route
383                    // validation the first time it ran, so this is purely
384                    // re-deriving `route_taken`, never re-validating.
385                    if let Some(route) = cached.get("route").and_then(|v| v.as_str()) {
386                        route_taken.insert(node.name.clone(), route.to_string());
387                    }
388                    outputs.insert(node.name.clone(), cached);
389                    continue;
390                }
391                Ok(None) => {}
392                Err(e) => {
393                    usage.duration_ms = started.elapsed().as_millis() as u64;
394                    break 'run fail(
395                        error_codes::SCHEMA_VALIDATION_FAILED,
396                        format!("reading ledger checkpoint for node `{}`: {e}", node.name),
397                        usage,
398                    );
399                }
400            }
401
402            // Tell watchers which node is running. A pipeline that stalls is
403            // much easier to diagnose when the stream says where.
404            if total > 1 {
405                let _ = events
406                    .send(JobEvent::Progress(serde_json::json!({
407                        "stage": index + 1,
408                        "of": total,
409                        "node": node.name,
410                    })))
411                    .await;
412            }
413
414            // Fresh branch-skip decision (only reached if the ledger had no
415            // recorded state for this node — i.e. this is either a fresh
416            // run, or a resumed run that hadn't reached this node yet).
417            //
418            // Branch-skip: this node is exclusive to a decision+label, and
419            // that decision's chosen route (already recorded earlier in this
420            // same loop, since the branching node is always topologically
421            // before the nodes exclusive to its labels) doesn't match.
422            if let Some(ex) = job.exclusive_to.get(&node.name) {
423                if let Some(taken) = route_taken.get(&ex.decision) {
424                    if taken != &ex.label {
425                        skipped.insert(node.name.clone());
426                        if let Err(e) = ledger.write_skipped(&node.name) {
427                            usage.duration_ms = started.elapsed().as_millis() as u64;
428                            break 'run fail(
429                                error_codes::SCHEMA_VALIDATION_FAILED,
430                                format!("recording skip for node `{}` in ledger: {e}", node.name),
431                                usage,
432                            );
433                        }
434                        continue;
435                    }
436                }
437            }
438            // Transitive skip: this node's input needs a skipped node's
439            // output.
440            if let Some(expr) = &node.input {
441                if references_any(expr, &skipped) {
442                    skipped.insert(node.name.clone());
443                    if let Err(e) = ledger.write_skipped(&node.name) {
444                        usage.duration_ms = started.elapsed().as_millis() as u64;
445                        break 'run fail(
446                            error_codes::SCHEMA_VALIDATION_FAILED,
447                            format!("recording skip for node `{}` in ledger: {e}", node.name),
448                            usage,
449                        );
450                    }
451                    continue;
452                }
453            }
454
455            let node_input = match &node.input {
456                None => job.input.clone(),
457                Some(expr) => evaluate_input(expr, &outputs),
458            };
459
460            // Bounded loop: repeat_until re-invokes this node on its own
461            // prior output, up to max_iterations. A node without
462            // repeat_until runs exactly once (the loop's `None` arm breaks
463            // immediately).
464            let mut current_input = node_input;
465            let mut iterations: u32 = 0;
466            let result = loop {
467                let r = match run_stage(
468                    &engine,
469                    &backend,
470                    &node.module_bytes,
471                    current_input.clone(),
472                    &job.caps,
473                    &mut handles,
474                    &events,
475                    &cancel,
476                    &mut usage,
477                    started,
478                    index,
479                )
480                .await
481                {
482                    Ok(v) => v,
483                    Err(envelope) => break 'run envelope,
484                };
485                match &node.repeat_until {
486                    None => break r,
487                    Some(field) => {
488                        iterations += 1;
489                        let done = r.get(field).and_then(|v| v.as_str()) == Some("done");
490                        if done {
491                            break r;
492                        }
493                        let max = node
494                            .max_iterations
495                            .expect("repeat_until requires max_iterations, enforced at parse time");
496                        if iterations >= max {
497                            usage.duration_ms = started.elapsed().as_millis() as u64;
498                            break 'run fail(
499                                error_codes::SCHEMA_VALIDATION_FAILED,
500                                format!(
501                                    "node `{}` did not reach repeat_until=\"done\" within max_iterations={max}",
502                                    node.name
503                                ),
504                                usage,
505                            );
506                        }
507                        current_input = r;
508                    }
509                }
510            };
511
512            // A branching node: read its `route` field and record the
513            // decision for later nodes' branch-skip check (top of this
514            // loop).
515            //
516            // Whether this node is actually a `branches` decision at all is
517            // determined from `job.exclusive_to` rather than by threading
518            // `Branches` itself into `JobSpec`: `dag::compute_branch_exclusivity`
519            // seeds an entry for every `(label, target)` pair of every declared
520            // decision, so the set of `label`s across all `exclusive_to` entries
521            // whose `decision` names this node IS exactly this decision's full,
522            // valid label set. An unmatched or missing `route` on a genuine
523            // decision node is a job failure — loud, not a silent no-op — per
524            // the design spec's "Conditional dispatch" section.
525            let valid_labels: Vec<&str> = job
526                .exclusive_to
527                .values()
528                .filter(|ex| ex.decision == node.name)
529                .map(|ex| ex.label.as_str())
530                .collect();
531            if !valid_labels.is_empty() {
532                match result.get("route").and_then(|v| v.as_str()) {
533                    Some(route) if valid_labels.contains(&route) => {
534                        route_taken.insert(node.name.clone(), route.to_string());
535                    }
536                    Some(route) => {
537                        usage.duration_ms = started.elapsed().as_millis() as u64;
538                        break 'run fail(
539                            error_codes::SCHEMA_VALIDATION_FAILED,
540                            format!(
541                                "node `{}` produced route \"{route}\", which doesn't match any \
542                                 declared label for this branches decision",
543                                node.name
544                            ),
545                            usage,
546                        );
547                    }
548                    None => {
549                        usage.duration_ms = started.elapsed().as_millis() as u64;
550                        break 'run fail(
551                            error_codes::SCHEMA_VALIDATION_FAILED,
552                            format!(
553                                "node `{}` is a branches decision but its output has no `route` field",
554                                node.name
555                            ),
556                            usage,
557                        );
558                    }
559                }
560            } else if let Some(route) = result.get("route").and_then(|v| v.as_str()) {
561                // Not a declared decision node, but its output happens to
562                // carry a route field anyway — harmless, record it
563                // defensively (no downstream node's exclusive_to can
564                // reference this decision name if it isn't a real decision,
565                // so this is inert either way, just avoids silently
566                // dropping information that happens to be present).
567                route_taken.insert(node.name.clone(), route.to_string());
568            }
569
570            // Checkpoint a genuinely-executed node's result before recording
571            // it in `outputs` — the ledger write must happen before the
572            // in-memory outputs map update so a crash between the two can't
573            // leave the ledger silently behind the in-memory state (the
574            // in-memory state doesn't survive a crash anyway, so
575            // ledger-first is the only order that matters for durability;
576            // the reverse order would just be a smaller window for the same
577            // underlying property, not correctness).
578            if let Err(e) = ledger.write_completed(&node.name, &result) {
579                usage.duration_ms = started.elapsed().as_millis() as u64;
580                break 'run fail(
581                    error_codes::SCHEMA_VALIDATION_FAILED,
582                    format!(
583                        "recording checkpoint for node `{}` in ledger: {e}",
584                        node.name
585                    ),
586                    usage,
587                );
588            }
589            outputs.insert(node.name.clone(), result);
590        }
591
592        usage.duration_ms = started.elapsed().as_millis() as u64;
593
594        // What is "the" job result for a graph? Convention: the output of the
595        // LAST node in topological order that wasn't skipped. This exactly
596        // matches today's linear-pipeline behavior in the degenerate case (a
597        // linear chain's last node in topo order is its sole sink), and
598        // generalizes sensibly to a branching graph (exactly one path executes,
599        // so there's still a well-defined "last node that actually ran") and to
600        // a fan-in graph without branches (the join/sink node is last in topo
601        // order, since nothing depends on it).
602        //
603        // Known limitation: for a graph with two or more independent sinks (no
604        // edge between them at all — `cuttlefishd`'s daemon path genuinely
605        // accepts such graphs, unlike `cuttlefish build`, which restricts itself
606        // to linear graphs), "last in topological order" is decided by the
607        // topological sort's tie-break (alphabetical node name among ready
608        // nodes), not by any deliberate semantic answer about which sink's
609        // output should represent the job. Don't mistake that tie-break for a
610        // considered multi-sink policy — it isn't one.
611        let result = job
612            .nodes
613            .iter()
614            .rev()
615            .find_map(|n| outputs.get(&n.name).cloned());
616
617        match result {
618            Some(value) => Envelope {
619                status: JobStatus::Completed,
620                result: Some(value),
621                error: None,
622                usage,
623            },
624            None => fail(
625                error_codes::SCHEMA_VALIDATION_FAILED,
626                "every node in this job was skipped; there is no result",
627                usage,
628            ),
629        }
630    };
631
632    let ledger_status = match envelope.status {
633        JobStatus::Completed => "completed",
634        JobStatus::Failed => "failed",
635        JobStatus::Cancelled => "cancelled",
636        _ => "running", // shouldn't occur — defensive only
637    };
638    if let Err(e) = ledger.finish(ledger_status) {
639        // A ledger write failure at this final step doesn't invalidate the
640        // job's already-computed, correct result — losing it here means a
641        // wrong Interrupted-detection on next restart, not a wrong result
642        // now. Loud enough to notice, not loud enough to discard real work
643        // that already succeeded or failed for its own, unrelated reason.
644        eprintln!("warning: failed to record job {ledger_status} status in ledger: {e}");
645    }
646    envelope
647}
648
649/// Run one block to completion, returning what it produced.
650///
651/// `Err` carries a finished [`Envelope`]: a stage that fails ends the whole job,
652/// because a later stage's input is the earlier one's output and there is
653/// nothing sensible to feed it.
654#[allow(clippy::too_many_arguments)]
655async fn run_stage(
656    engine: &Engine,
657    backend: &Arc<dyn InferBackend>,
658    module_bytes: &[u8],
659    input: serde_json::Value,
660    caps: &Capabilities,
661    handles: &mut Handles,
662    events: &mpsc::Sender<JobEvent>,
663    cancel: &CancellationToken,
664    usage: &mut Usage,
665    started: Instant,
666    stage_index: usize,
667) -> Result<serde_json::Value, Envelope> {
668    // Naming the stage turns "the job failed" into "the second block failed",
669    // which is the difference between a usable error and a hunt.
670    let blame = |message: String| -> String {
671        if stage_index == 0 {
672            message
673        } else {
674            format!("block {} of the pipeline: {message}", stage_index + 1)
675        }
676    };
677    // Documents are read from their path rather than their descriptor — both
678    // extraction and rendering want a file. Kept beside the handle table so the
679    // two are dropped together at the end of the job.
680    let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
681        std::collections::HashMap::new();
682
683    let mut guest = match Guest::new(engine, module_bytes) {
684        Ok(g) => g,
685        Err(e) => {
686            return Err(fail(
687                error_codes::WASM_TRAP,
688                blame(e.to_string()),
689                usage.clone(),
690            ))
691        }
692    };
693
694    let mut command = match guest.call_init(&input) {
695        Ok(c) => c,
696        Err(e) => {
697            return Err(fail(
698                error_codes::WASM_TRAP,
699                blame(e.to_string()),
700                usage.clone(),
701            ))
702        }
703    };
704
705    loop {
706        if cancel.is_cancelled() {
707            usage.duration_ms = started.elapsed().as_millis() as u64;
708            return Err(cancelled(usage.clone(), "job cancelled"));
709        }
710
711        let event = match command {
712            // Ends this stage, not the job: the value becomes the next
713            // block's input, or the job's result if this was the last.
714            Command::Done { result } => return Ok(result),
715            Command::Fail { code, message } => {
716                usage.duration_ms = started.elapsed().as_millis() as u64;
717                return Err(fail(&code, blame(message), usage.clone()));
718            }
719            Command::Emit { progress } => {
720                let _ = events.send(JobEvent::Progress(progress)).await;
721                Event::Emitted
722            }
723
724            // The capability check lives here, at Open, and nowhere else. Slice
725            // takes a handle rather than a path, and handles are job-scoped, so
726            // there is no second place a path can enter the system.
727            Command::Open { path } => {
728                let p = std::path::PathBuf::from(&path);
729                if !caps.allows_read(&p) {
730                    usage.duration_ms = started.elapsed().as_millis() as u64;
731                    return Err(fail(
732                        error_codes::CAPABILITY_DENIED,
733                        format!("read not permitted: {path}"),
734                        usage.clone(),
735                    ));
736                }
737                match handles.open(&p) {
738                    Ok((handle, len, kind)) => {
739                        // A PDF's page count and text layer need the whole file,
740                        // which the handle layer deliberately does not read. Ask
741                        // the document layer, and fall back to the plain kind if
742                        // it cannot answer — a malformed PDF is still a file a
743                        // block may want to read bytes from.
744                        let kind = match kind {
745                            cuttlefish_abi::MediaKind::Document { .. } => {
746                                match crate::documents::inspect(&p) {
747                                    Ok(info) => cuttlefish_abi::MediaKind::Document {
748                                        pages: info.pages,
749                                        has_text_layer: info.has_text_layer,
750                                    },
751                                    Err(_) => cuttlefish_abi::MediaKind::Binary,
752                                }
753                            }
754                            other => other,
755                        };
756                        // Remember the path: rendering and text extraction work
757                        // from a file, not from the open descriptor.
758                        doc_paths.insert(handle, p.clone());
759                        Event::Opened { handle, len, kind }
760                    }
761                    Err(e) => {
762                        usage.duration_ms = started.elapsed().as_millis() as u64;
763                        return Err(fail(
764                            error_codes::CAPABILITY_DENIED,
765                            e.to_string(),
766                            usage.clone(),
767                        ));
768                    }
769                }
770            }
771
772            Command::Slice {
773                handle,
774                offset,
775                len,
776            } => match handles.slice(handle, offset, len) {
777                Ok(w) => Event::Sliced {
778                    text: w.text,
779                    next_offset: w.next_offset,
780                },
781                Err(e) => {
782                    usage.duration_ms = started.elapsed().as_millis() as u64;
783                    return Err(fail(
784                        error_codes::CAPABILITY_DENIED,
785                        e.to_string(),
786                        usage.clone(),
787                    ));
788                }
789            },
790
791            Command::SliceBytes {
792                handle,
793                offset,
794                len,
795            } => match handles.slice_bytes(handle, offset, len) {
796                Ok((bytes, next_offset)) => {
797                    use base64::Engine;
798                    Event::SlicedBytes {
799                        bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
800                        next_offset,
801                    }
802                }
803                Err(e) => {
804                    usage.duration_ms = started.elapsed().as_millis() as u64;
805                    return Err(fail(
806                        error_codes::CAPABILITY_DENIED,
807                        e.to_string(),
808                        usage.clone(),
809                    ));
810                }
811            },
812
813            Command::PageText { handle, page } => {
814                let Some(path) = doc_paths.get(&handle).cloned() else {
815                    usage.duration_ms = started.elapsed().as_millis() as u64;
816                    return Err(fail(
817                        error_codes::CAPABILITY_DENIED,
818                        format!("no such handle: {handle}"),
819                        usage.clone(),
820                    ));
821                };
822                match crate::documents::page_text(&path, page) {
823                    Ok(text) => Event::PageTexted { text },
824                    Err(e) => {
825                        usage.duration_ms = started.elapsed().as_millis() as u64;
826                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
827                    }
828                }
829            }
830
831            Command::PageImage { handle, page } => {
832                let Some(path) = doc_paths.get(&handle).cloned() else {
833                    usage.duration_ms = started.elapsed().as_millis() as u64;
834                    return Err(fail(
835                        error_codes::CAPABILITY_DENIED,
836                        format!("no such handle: {handle}"),
837                        usage.clone(),
838                    ));
839                };
840                // A rendered page becomes a handle like any other, so it can be
841                // named in Infer exactly as a file-backed image would be.
842                match crate::documents::render_page(&path, page, RENDER_WIDTH) {
843                    Ok(png) => {
844                        let (handle, len) = handles.insert_bytes(
845                            png,
846                            cuttlefish_abi::MediaKind::Image {
847                                format: "png".into(),
848                            },
849                        );
850                        Event::PageImaged { handle, len }
851                    }
852                    Err(e) => {
853                        usage.duration_ms = started.elapsed().as_millis() as u64;
854                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
855                    }
856                }
857            }
858
859            Command::Infer {
860                prompt,
861                max_tokens,
862                images,
863            } => {
864                // Images are named by handle; the host loads the bytes, so they
865                // never pass through guest memory.
866                // Refuse rather than drop. Sending images to a backend that
867                // cannot use them produces a confident answer about nothing,
868                // which reads as a bad model rather than a misconfigured job —
869                // and the caller has no way to tell the difference.
870                if !images.is_empty() && !backend.supports_images() {
871                    usage.duration_ms = started.elapsed().as_millis() as u64;
872                    return Err(fail(
873                        error_codes::UNSUPPORTED,
874                        format!(
875                            "this job supplied {} image(s), but the backend serving `{}` cannot \
876                             accept them. Use a vision-capable model through the `ollama` \
877                             provider, or change the block to send text only.",
878                            images.len(),
879                            backend.model_name()
880                        ),
881                        usage.clone(),
882                    ));
883                }
884                let mut image_bytes = Vec::with_capacity(images.len());
885                for handle in &images {
886                    match handles.read_all(*handle) {
887                        Ok(bytes) => image_bytes.push(bytes),
888                        Err(e) => {
889                            usage.duration_ms = started.elapsed().as_millis() as u64;
890                            return Err(fail(
891                                error_codes::CAPABILITY_DENIED,
892                                e.to_string(),
893                                usage.clone(),
894                            ));
895                        }
896                    }
897                }
898
899                // Tokens must reach the guest *while* generation runs, because
900                // the guest's Stop verdict is what ends it early. The wasmtime
901                // Store is !Sync and cannot be touched from inside the backend's
902                // callback, so a channel carries tokens out and a shared flag
903                // carries the verdict back — without sharing the Store.
904                let (tx, mut rx) = mpsc::unbounded_channel::<String>();
905                let stop = Arc::new(AtomicBool::new(false));
906                let sink_stop = stop.clone();
907                let mut sink = move |t: &str| {
908                    tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
909                };
910
911                let mut trap: Option<String> = None;
912                let outcome: Option<anyhow::Result<InferResult>> = {
913                    let request = InferRequest {
914                        prompt: &prompt,
915                        max_tokens,
916                        images: &image_bytes,
917                    };
918                    let infer = backend.infer(request, &mut sink);
919                    tokio::pin!(infer);
920                    loop {
921                        tokio::select! {
922                            biased;
923                            _ = cancel.cancelled() => break None,
924                            Some(tok) = rx.recv() => {
925                                let _ = events.send(JobEvent::Token(tok.clone())).await;
926                                match guest.call_on_token(&tok) {
927                                    Ok(true) => {}
928                                    Ok(false) => stop.store(true, Ordering::Relaxed),
929                                    Err(e) => {
930                                        trap = Some(e.to_string());
931                                        break None;
932                                    }
933                                }
934                            }
935                            r = &mut infer => break Some(r),
936                        }
937                    }
938                };
939
940                if let Some(message) = trap {
941                    usage.duration_ms = started.elapsed().as_millis() as u64;
942                    return Err(fail(error_codes::WASM_TRAP, message, usage.clone()));
943                }
944
945                // Tokens generated in the same poll as the last one are still
946                // queued; forward them so the stream is complete.
947                while let Ok(tok) = rx.try_recv() {
948                    let _ = events.send(JobEvent::Token(tok)).await;
949                }
950
951                match outcome {
952                    None => {
953                        usage.duration_ms = started.elapsed().as_millis() as u64;
954                        return Err(cancelled(usage.clone(), "cancelled during inference"));
955                    }
956                    Some(Err(e)) => {
957                        usage.duration_ms = started.elapsed().as_millis() as u64;
958                        return Err(fail(
959                            error_codes::MODEL_LOAD_FAILED,
960                            e.to_string(),
961                            usage.clone(),
962                        ));
963                    }
964                    Some(Ok(r)) => {
965                        usage.tokens_in += r.tokens_in;
966                        usage.tokens_out += r.tokens_out;
967                        Event::InferDone {
968                            text: r.text,
969                            tokens_out: r.tokens_out,
970                        }
971                    }
972                }
973            }
974        };
975
976        command = match guest.call_step(&event) {
977            Ok(c) => c,
978            Err(e) => {
979                usage.duration_ms = started.elapsed().as_millis() as u64;
980                return Err(fail(
981                    error_codes::WASM_TRAP,
982                    blame(e.to_string()),
983                    usage.clone(),
984                ));
985            }
986        };
987    }
988}