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/// Backends for models *other than* the job's own, keyed by the model that
46/// names them.
47///
48/// Named `alternates` rather than `backends` on purpose: `run_job` already
49/// takes the job's own backend, and a field called `backends` sitting beside
50/// a parameter called `backend` is a trap. Everything here is something the
51/// job reaches only by explicit instruction — an `on_fail = [ reroute ... ]`
52/// rung, or a `Judge` that names its own model.
53///
54/// Resolved once at daemon startup, so a model this build cannot serve fails
55/// as the daemon comes up rather than three hours into a campaign, at the
56/// exact moment something has already gone wrong.
57pub type Alternates =
58    std::collections::HashMap<cuttlefish_core::spec::ModelRef, Arc<dyn InferBackend>>;
59
60/// Every model a spec can reach *besides* its own — the `reroute` targets
61/// and the model-bearing `Judge`s, deduplicated.
62///
63/// Exists so the daemon can resolve them all at startup. Collecting them
64/// here rather than in `cuttlefishd` keeps the knowledge of which node
65/// fields name a model next to the type that consumes them, so adding a
66/// third such field later is one edit rather than two.
67pub fn alternate_models_of(
68    spec: &cuttlefish_core::spec::Spec,
69) -> Vec<cuttlefish_core::spec::ModelRef> {
70    use cuttlefish_core::graph::{AcceptCheck, Rung};
71    let mut seen: Vec<cuttlefish_core::spec::ModelRef> = Vec::new();
72    let mut push = |model: cuttlefish_core::spec::ModelRef| {
73        // The job's own model is not an alternate; it already has a backend.
74        if model != spec.model && !seen.contains(&model) {
75            seen.push(model);
76        }
77    };
78    for (_, node) in &spec.nodes.nodes {
79        for rung in &node.on_fail {
80            if let Rung::Reroute(model) = rung {
81                push(model.clone());
82            }
83        }
84        for check in &node.accept {
85            if let AcceptCheck::Judge {
86                model: Some(model), ..
87            } = check
88            {
89                push(model.clone());
90            }
91        }
92    }
93    seen
94}
95
96/// Everything needed to run one job.
97pub struct JobSpec {
98    /// The checked graph, in topological order — safe to execute
99    /// front-to-back, threading `outputs` forward.
100    pub nodes: Vec<crate::dag::CheckedNode>,
101    /// Which nodes are exclusive to which branch decision+label — see
102    /// `crate::dag::BranchExclusivity`.
103    pub exclusive_to: std::collections::HashMap<String, crate::dag::BranchExclusivity>,
104    /// The job's input, handed to every entry node (a node with no `input`
105    /// expression — `node.input.is_none()`).
106    pub input: serde_json::Value,
107    /// What this job is permitted to reach.
108    pub caps: Capabilities,
109    /// The backend serving `embed`, if the spec declared an
110    /// `embedding_model`. Resolved at startup like every other model, so a
111    /// spec naming one this build cannot serve fails as the daemon comes up
112    /// rather than partway through a corpus.
113    pub embedder: Option<Arc<dyn InferBackend>>,
114    /// Where to write a medallion warehouse of this job's results, and the
115    /// lineage to stamp on every row. `None` for a spec that declared no
116    /// `warehouse`, which is every spec that existed before this.
117    pub warehouse: Option<WarehousePlan>,
118    /// Backends for models beyond the job's own — see [`Alternates`].
119    /// Empty for a spec with no `reroute` rung and no model-bearing `Judge`,
120    /// which is every spec that existed before acceptance contracts.
121    pub alternates: Alternates,
122}
123
124/// Pointer width of a guest module, read from the module rather than assumed.
125///
126/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
127/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
128/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
129/// file for every place a pointer was assumed to be four bytes wide.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Abi {
132    /// 32-bit linear memory.
133    W32,
134    /// 64-bit linear memory (memory64).
135    W64,
136}
137
138impl Abi {
139    /// Size of one pointer-sized field, and so half a descriptor.
140    fn ptr_size(self) -> usize {
141        match self {
142            Abi::W32 => 4,
143            Abi::W64 => 8,
144        }
145    }
146}
147
148struct Guest {
149    store: Store<()>,
150    memory: Memory,
151    abi: Abi,
152    alloc: TypedFunc<u32, u32>,
153    init: TypedFunc<(u32, u32), u32>,
154    step: TypedFunc<(u32, u32), u32>,
155    on_token: Option<TypedFunc<(u32, u32), i32>>,
156}
157
158impl Guest {
159    fn new(
160        engine: &Engine,
161        cache: &crate::module_cache::ModuleCache,
162        module_bytes: &[u8],
163    ) -> anyhow::Result<Self> {
164        let module = cache.compile(engine, module_bytes)?;
165
166        // An empty linker, deliberately. Guest blocks are built for
167        // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
168        // would drag in `fd_write` and `proc_exit` through its panic path alone
169        // and fail to instantiate here.
170        let linker: Linker<()> = Linker::new(engine);
171        let mut store = Store::new(engine, ());
172        let instance: Instance = linker.instantiate(&mut store, &module)?;
173
174        let memory = instance
175            .get_memory(&mut store, "memory")
176            .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
177
178        // Width comes from the module itself. A 64-bit guest exports `cf_init`
179        // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
180        // fail with a signature mismatch that says nothing about the real cause.
181        let abi = if memory.ty(&store).is_64() {
182            Abi::W64
183        } else {
184            Abi::W32
185        };
186        if abi == Abi::W64 {
187            anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
188        }
189
190        Ok(Self {
191            alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
192            init: instance.get_typed_func(&mut store, "cf_init")?,
193            step: instance.get_typed_func(&mut store, "cf_step")?,
194            // Optional: a block indifferent to streaming need not export it.
195            on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
196            memory,
197            abi,
198            store,
199        })
200    }
201
202    fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
203        let len = bytes.len() as u32;
204        let ptr = self.alloc.call(&mut self.store, len)?;
205        self.memory.write(&mut self.store, ptr as usize, bytes)?;
206        Ok((ptr, len))
207    }
208
209    /// Read the descriptor the guest returned, then the payload it points at.
210    ///
211    /// Two reads rather than unpacking one integer — the cost of keeping these
212    /// signatures identical across pointer widths.
213    fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
214        let w = self.abi.ptr_size();
215        let mut desc = vec![0u8; 2 * w];
216        self.memory
217            .read(&mut self.store, desc_ptr as usize, &mut desc)?;
218
219        let field = |bytes: &[u8]| -> u64 {
220            match w {
221                4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
222                _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
223            }
224        };
225        let ptr = field(&desc[..w]) as usize;
226        let len = field(&desc[w..]) as usize;
227
228        let mut buf = vec![0u8; len];
229        self.memory.read(&mut self.store, ptr, &mut buf)?;
230        Ok(buf)
231    }
232
233    fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
234        let bytes = serde_json::to_vec(input)?;
235        let (ptr, len) = self.write(&bytes)?;
236        let desc = self.init.call(&mut self.store, (ptr, len))?;
237        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
238    }
239
240    fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
241        let bytes = serde_json::to_vec(event)?;
242        let (ptr, len) = self.write(&bytes)?;
243        let desc = self.step.call(&mut self.store, (ptr, len))?;
244        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
245    }
246
247    /// Ask the guest whether generation should continue.
248    fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
249        // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
250        // and cloning also ends the borrow of `self` before `write` needs it
251        // mutably.
252        let Some(f) = self.on_token.clone() else {
253            return Ok(true);
254        };
255        let (ptr, len) = self.write(token.as_bytes())?;
256        Ok(f.call(&mut self.store, (ptr, len))? == 0)
257    }
258}
259
260/// Read a block's declared signature out of a compiled module.
261///
262/// Instantiates the module and calls its `cf_signature` export. That is heavier
263/// than parsing a sidecar file, and it is the point: the answer comes from the
264/// artifact that will actually run, so it cannot describe a different version of
265/// the block than the one being checked.
266///
267/// A block built before signatures existed has no such export. That is not an
268/// error — it reports the permissive default, so an older block still composes,
269/// just without the seam being checked.
270pub fn read_signature(
271    engine: &Engine,
272    module_bytes: &[u8],
273) -> anyhow::Result<cuttlefish_abi::Signature> {
274    let permissive = cuttlefish_abi::Signature {
275        input: cuttlefish_abi::Ty::Json,
276        output: cuttlefish_abi::Ty::Json,
277    };
278
279    // Deliberately does not go through `Guest`, which requires the whole reactor
280    // — alloc, init, step. Reading a declaration should not demand that a module
281    // be runnable: a block missing an export has a real problem, but it is one
282    // worth reporting when the job runs, with the job's error handling, rather
283    // than as a confusing failure during a typecheck.
284    let module = Module::new(engine, module_bytes)?;
285    let linker: Linker<()> = Linker::new(engine);
286    let mut store = Store::new(engine, ());
287    let instance = linker.instantiate(&mut store, &module)?;
288
289    let Ok(signature) = instance.get_typed_func::<(), u32>(&mut store, "cf_signature") else {
290        return Ok(permissive);
291    };
292    let Some(memory) = instance.get_memory(&mut store, "memory") else {
293        return Ok(permissive);
294    };
295
296    let desc_ptr = signature.call(&mut store, ())? as usize;
297    let mut desc = [0u8; 8];
298    memory.read(&mut store, desc_ptr, &mut desc)?;
299    let ptr = u32::from_le_bytes(desc[..4].try_into().expect("4 bytes")) as usize;
300    let len = u32::from_le_bytes(desc[4..].try_into().expect("4 bytes")) as usize;
301
302    let mut buf = vec![0u8; len];
303    memory.read(&mut store, ptr, &mut buf)?;
304    Ok(serde_json::from_slice(&buf)?)
305}
306
307fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
308    Envelope {
309        status: JobStatus::Failed,
310        result: None,
311        error: Some(JobError {
312            code: code.into(),
313            message: message.into(),
314        }),
315        usage,
316    }
317}
318
319/// Whether an `InputExpr` (transitively) references any node in `skipped`.
320fn references_any(
321    expr: &cuttlefish_core::graph::InputExpr,
322    skipped: &std::collections::HashSet<String>,
323) -> bool {
324    use cuttlefish_core::graph::InputExpr;
325    match expr {
326        InputExpr::FromNode(n) => skipped.contains(n),
327        InputExpr::Record(fields) => fields.values().any(|e| references_any(e, skipped)),
328        InputExpr::List(items) => items.iter().any(|e| references_any(e, skipped)),
329    }
330}
331
332/// Compose an `InputExpr` into an actual JSON value, by looking up each
333/// referenced node's already-produced output. Mirrors `dag::evaluate_expr_ty`
334/// (which does the same composition at the *type* level, at check time) —
335/// this is the runtime analogue.
336fn evaluate_input(
337    expr: &cuttlefish_core::graph::InputExpr,
338    outputs: &std::collections::HashMap<String, serde_json::Value>,
339) -> serde_json::Value {
340    use cuttlefish_core::graph::InputExpr;
341    match expr {
342        InputExpr::FromNode(n) => outputs.get(n).cloned().unwrap_or(serde_json::Value::Null),
343        InputExpr::Record(fields) => {
344            let mut map = serde_json::Map::new();
345            for (k, v) in fields {
346                map.insert(k.clone(), evaluate_input(v, outputs));
347            }
348            serde_json::Value::Object(map)
349        }
350        InputExpr::List(items) => {
351            serde_json::Value::Array(items.iter().map(|e| evaluate_input(e, outputs)).collect())
352        }
353    }
354}
355
356fn cancelled(usage: Usage, message: &str) -> Envelope {
357    Envelope {
358        status: JobStatus::Cancelled,
359        result: None,
360        error: Some(JobError {
361            code: error_codes::CANCELLED.into(),
362            message: message.into(),
363        }),
364        usage,
365    }
366}
367
368/// Where a job's warehouse goes, and what lineage its rows carry.
369///
370/// The job-level half of [`crate::warehouse::Lineage`]: the per-row half comes
371/// from the ledger. Assembled once at submit time so the runner never has to
372/// reach back into the spec.
373#[derive(Debug, Clone)]
374pub struct WarehousePlan {
375    /// The warehouse root, already resolved against the spec's directory.
376    pub root: std::path::PathBuf,
377    /// The spec's name, stamped on every row.
378    pub spec_name: String,
379    /// The chat model, as resolved.
380    pub model: String,
381    /// The embedding model, if the spec declared one.
382    pub embedding_model: Option<String>,
383}
384
385/// What the warehouse needs to know about a node.
386///
387/// Deliberately not a [`crate::dag::CheckedNode`]: that carries the node's
388/// compiled wasm, and the warehouse would have to clone every byte of every
389/// module to hold onto it past the run.
390#[derive(Debug, Clone)]
391pub struct WarehouseNode {
392    name: String,
393    /// Only a fan-out node has per-item rows to put in bronze and silver.
394    is_fanout: bool,
395    /// The declared per-item output, which silver types against.
396    item_output: Option<cuttlefish_abi::Ty>,
397    /// The declared node output, which gold types against.
398    output_ty: cuttlefish_abi::Ty,
399}
400
401impl WarehouseNode {
402    fn of(node: &crate::dag::CheckedNode) -> Self {
403        Self {
404            name: node.name.clone(),
405            is_fanout: node.over.is_some(),
406            item_output: node.item_output.clone(),
407            output_ty: node.signature.output.clone(),
408        }
409    }
410}
411
412/// Write the job's warehouse, reading everything back from the ledger.
413///
414/// Written once, at the end, rather than per node as each concludes. The
415/// ledger is the authority and the warehouse is a projection of it — so a job
416/// that dies partway leaves no half-warehouse to mistake for a whole one, and
417/// a resumed job rewrites the projection from the complete record rather than
418/// appending to a partial one.
419///
420/// Failures here are reported and swallowed. A warehouse is a *projection* of
421/// results the job already computed and already recorded; discarding a
422/// finished corpus because a disk filled while writing a derived file would
423/// throw away the expensive thing to protect the cheap one. Re-running the
424/// job — or simply resuming it — rewrites it.
425fn write_warehouse(
426    plan: &WarehousePlan,
427    nodes: &[WarehouseNode],
428    ledger: &crate::ledger::Ledger,
429    envelope: &Envelope,
430) {
431    use crate::warehouse as wh;
432
433    let job_id = ledger
434        .job_dir()
435        .file_name()
436        .map(|n| n.to_string_lossy().into_owned())
437        .unwrap_or_else(|| "unknown".into());
438
439    let lineage = wh::Lineage {
440        job_id: job_id.clone(),
441        spec_name: plan.spec_name.clone(),
442        spec_fingerprint: ledger.graph_fingerprint().unwrap_or_default(),
443        model: plan.model.clone(),
444        embedding_model: plan.embedding_model.clone(),
445        cuttlefish_version: env!("CARGO_PKG_VERSION").to_string(),
446    };
447
448    let (mut bronze, mut silver, mut gold) = (
449        std::collections::BTreeMap::new(),
450        std::collections::BTreeMap::new(),
451        std::collections::BTreeMap::new(),
452    );
453
454    for node in nodes {
455        // Only a fan-out node has per-item rows. A plain node's single output
456        // is the job result, and lands in gold.
457        if !node.is_fanout {
458            continue;
459        }
460        let rows: Vec<wh::Row> = match ledger.concluded_rows(&node.name) {
461            Ok(rows) => rows
462                .into_iter()
463                .map(|r| wh::Row {
464                    node: node.name.clone(),
465                    item: r.item,
466                    status: r.status,
467                    concluded_at: r.concluded_at,
468                    source_input: r.source_input,
469                    output: r.output,
470                    error: r.error,
471                })
472                .collect(),
473            Err(e) => {
474                eprintln!("warning: warehouse: reading node `{}`: {e}", node.name);
475                continue;
476            }
477        };
478        if rows.is_empty() {
479            continue;
480        }
481
482        match wh::bronze_batch(&rows, &lineage) {
483            Ok(batch) => {
484                let path = plan
485                    .root
486                    .join("bronze")
487                    .join(format!("{}.parquet", node.name));
488                match wh::write_parquet(&path, &batch) {
489                    Ok(()) => {
490                        bronze.insert(
491                            node.name.clone(),
492                            wh::Layer::Written(wh::entry_for(&plan.root, &path, &batch)),
493                        );
494                    }
495                    Err(e) => eprintln!("warning: warehouse: writing bronze: {e}"),
496                }
497            }
498            Err(e) => eprintln!("warning: warehouse: building bronze: {e}"),
499        }
500
501        let item_output = node.item_output.clone().unwrap_or(cuttlefish_abi::Ty::Json);
502        match wh::silver_batch(&rows, &lineage, &item_output) {
503            Ok(Some(batch)) => {
504                let path = plan
505                    .root
506                    .join("silver")
507                    .join(format!("{}.parquet", node.name));
508                match wh::write_parquet(&path, &batch) {
509                    Ok(()) => {
510                        silver.insert(
511                            node.name.clone(),
512                            wh::Layer::Written(wh::entry_for(&plan.root, &path, &batch)),
513                        );
514                    }
515                    Err(e) => eprintln!("warning: warehouse: writing silver: {e}"),
516                }
517            }
518            // Recorded, not omitted: a reader finding no silver table has to
519            // be able to tell "the author declared no shape to validate
520            // against" from "the job broke before writing it".
521            Ok(None) => {
522                silver.insert(
523                    node.name.clone(),
524                    wh::Layer::Skipped {
525                        skipped: format!(
526                            "node `{}` declares its per-item output as `{}`, which names no \
527                             fields to validate against — declare a record of `text`, `number` \
528                             or `bool` fields to get a typed silver table",
529                            node.name,
530                            item_output.describe()
531                        ),
532                    },
533                );
534            }
535            Err(e) => eprintln!("warning: warehouse: building silver: {e}"),
536        }
537    }
538
539    // Gold is the job's own result: the rollup, curated by whoever wrote the
540    // spec. One row, typed against the final node's declared output for the
541    // same reason silver is.
542    if let (Some(value), Some(last)) = (envelope.result.as_ref(), nodes.last()) {
543        let row = wh::Row {
544            node: last.name.clone(),
545            item: 0,
546            status: "completed".into(),
547            concluded_at: wh::now_rfc3339(),
548            source_input: None,
549            output: Some(value.clone()),
550            error: None,
551        };
552        let ty = last.output_ty.clone();
553        match wh::silver_batch(std::slice::from_ref(&row), &lineage, &ty) {
554            Ok(Some(batch)) => {
555                let path = plan
556                    .root
557                    .join("gold")
558                    .join(format!("{}.parquet", last.name));
559                match wh::write_parquet(&path, &batch) {
560                    Ok(()) => {
561                        gold.insert(
562                            last.name.clone(),
563                            wh::Layer::Written(wh::entry_for(&plan.root, &path, &batch)),
564                        );
565                    }
566                    Err(e) => eprintln!("warning: warehouse: writing gold: {e}"),
567                }
568            }
569            Ok(None) => {
570                gold.insert(
571                    last.name.clone(),
572                    wh::Layer::Skipped {
573                        skipped: format!(
574                            "node `{}` declares its output as `{}`, which names no fields to \
575                             validate against",
576                            last.name,
577                            ty.describe()
578                        ),
579                    },
580                );
581            }
582            Err(e) => eprintln!("warning: warehouse: building gold: {e}"),
583        }
584    }
585
586    let manifest = wh::Manifest {
587        job_id,
588        spec_name: plan.spec_name.clone(),
589        spec_fingerprint: lineage.spec_fingerprint.clone(),
590        model: plan.model.clone(),
591        embedding_model: plan.embedding_model.clone(),
592        cuttlefish_version: lineage.cuttlefish_version.clone(),
593        written_at: wh::now_rfc3339(),
594        bronze,
595        silver,
596        gold,
597    };
598    if let Err(e) = wh::write_manifest(&plan.root, &manifest) {
599        eprintln!("warning: warehouse: writing the manifest: {e}");
600    }
601}
602
603/// Drive one job to completion.
604///
605/// Always returns an [`Envelope`]; failures are values, not errors, because the
606/// caller has to report *something* to whoever submitted the job.
607///
608/// `ledger` is consulted before any resume-sensitive decision (branch-skip,
609/// transitive-skip, or actually running a node) and written to immediately
610/// after that decision is made, so that a process restart mid-job — a later
611/// task's concern, not this function's — can resume from exactly the state
612/// this function left behind. The whole body runs inside a single labeled
613/// block (`'run: { ... }`) so that every exit path, however it got there,
614/// still reaches the one `ledger.finish(...)` call at the end.
615pub async fn run_job(
616    engine: Arc<Engine>,
617    backend: Arc<dyn InferBackend>,
618    job: JobSpec,
619    events: mpsc::Sender<JobEvent>,
620    cancel: CancellationToken,
621    ledger: &crate::ledger::Ledger,
622    cache: &crate::module_cache::ModuleCache,
623) -> Envelope {
624    let started = Instant::now();
625    let mut usage = Usage {
626        model: backend.model_name(),
627        ..Usage::default()
628    };
629
630    // Dropped when this function returns, closing every file the job opened.
631    // That job-scoped lifetime is what makes handles unforgeable across jobs.
632    //
633    // Shared across stages on purpose: a handle produced by one block — a
634    // rendered page, say — stays usable by the next. Confining it to one stage
635    // would make a pipeline strictly weaker than a single block that did the
636    // same work, while adding nothing, since the job boundary is what the
637    // security property rests on.
638    let mut handles = Handles::default();
639
640    // Captured before the run consumes `job`: the warehouse is written after
641    // the run block, and only what it needs is kept — see [`WarehouseNode`].
642    let job_warehouse = job.warehouse.clone();
643    let warehouse_nodes: Vec<WarehouseNode> = match &job_warehouse {
644        Some(_) => job.nodes.iter().map(WarehouseNode::of).collect(),
645        None => Vec::new(),
646    };
647
648    let envelope = 'run: {
649        if job.nodes.is_empty() {
650            usage.duration_ms = started.elapsed().as_millis() as u64;
651            break 'run fail(
652                error_codes::SCHEMA_VALIDATION_FAILED,
653                "this job has no nodes to run",
654                usage,
655            );
656        }
657
658        // Compile every node's `accept` list up front, indexed by node
659        // position. A malformed schema is a property of the spec, so it
660        // should stop the job at the start rather than surface as a bizarre
661        // acceptance failure once half a campaign has already run.
662        let checks: Vec<crate::accept::CompiledChecks> = match job
663            .nodes
664            .iter()
665            .map(|n| {
666                crate::accept::CompiledChecks::compile(&n.accept)
667                    .map_err(|e| format!("node `{}`: {e}", n.name))
668            })
669            .collect()
670        {
671            Ok(c) => c,
672            Err(message) => {
673                usage.duration_ms = started.elapsed().as_millis() as u64;
674                break 'run fail(error_codes::SCHEMA_VALIDATION_FAILED, message, usage);
675            }
676        };
677
678        let total = job.nodes.len();
679        let mut outputs: std::collections::HashMap<String, serde_json::Value> =
680            std::collections::HashMap::new();
681        let mut skipped: std::collections::HashSet<String> = std::collections::HashSet::new();
682        let mut route_taken: std::collections::HashMap<String, String> =
683            std::collections::HashMap::new();
684
685        for (index, node) in job.nodes.iter().enumerate() {
686            // Resume: a node the ledger already marked skipped stays skipped
687            // — its branch decision is not re-evaluated.
688            match ledger.is_skipped(&node.name) {
689                Ok(true) => {
690                    skipped.insert(node.name.clone());
691                    continue;
692                }
693                Ok(false) => {}
694                Err(e) => {
695                    usage.duration_ms = started.elapsed().as_millis() as u64;
696                    break 'run fail(
697                        error_codes::SCHEMA_VALIDATION_FAILED,
698                        format!("reading ledger skip state for node `{}`: {e}", node.name),
699                        usage,
700                    );
701                }
702            }
703
704            // Resume: a completed checkpoint means reuse the cached output
705            // instead of re-running.
706            match ledger.get_completed(&node.name) {
707                Ok(Some(cached)) => {
708                    // If this was a branches decision node, its route must be
709                    // recorded in `route_taken` here too — not just after a
710                    // fresh `run_stage` call below — or a downstream,
711                    // not-yet-reached branch-exclusive node would see no
712                    // recorded decision at all on a resumed run and
713                    // (incorrectly) never be skipped. The checkpoint only
714                    // ever holds a value that already passed this same route
715                    // validation the first time it ran, so this is purely
716                    // re-deriving `route_taken`, never re-validating.
717                    if let Some(route) = cached.get("route").and_then(|v| v.as_str()) {
718                        route_taken.insert(node.name.clone(), route.to_string());
719                    }
720                    outputs.insert(node.name.clone(), cached);
721                    continue;
722                }
723                Ok(None) => {}
724                Err(e) => {
725                    usage.duration_ms = started.elapsed().as_millis() as u64;
726                    break 'run fail(
727                        error_codes::SCHEMA_VALIDATION_FAILED,
728                        format!("reading ledger checkpoint for node `{}`: {e}", node.name),
729                        usage,
730                    );
731                }
732            }
733
734            // Tell watchers which node is running. A pipeline that stalls is
735            // much easier to diagnose when the stream says where.
736            if total > 1 {
737                let _ = events
738                    .send(JobEvent::Progress(serde_json::json!({
739                        "stage": index + 1,
740                        "of": total,
741                        "node": node.name,
742                    })))
743                    .await;
744            }
745
746            // Fresh branch-skip decision (only reached if the ledger had no
747            // recorded state for this node — i.e. this is either a fresh
748            // run, or a resumed run that hadn't reached this node yet).
749            //
750            // Branch-skip: this node is exclusive to a decision+label, and
751            // that decision's chosen route (already recorded earlier in this
752            // same loop, since the branching node is always topologically
753            // before the nodes exclusive to its labels) doesn't match.
754            if let Some(ex) = job.exclusive_to.get(&node.name) {
755                if let Some(taken) = route_taken.get(&ex.decision) {
756                    if taken != &ex.label {
757                        skipped.insert(node.name.clone());
758                        if let Err(e) = ledger.write_skipped(&node.name) {
759                            usage.duration_ms = started.elapsed().as_millis() as u64;
760                            break 'run fail(
761                                error_codes::SCHEMA_VALIDATION_FAILED,
762                                format!("recording skip for node `{}` in ledger: {e}", node.name),
763                                usage,
764                            );
765                        }
766                        continue;
767                    }
768                }
769            }
770            // Transitive skip: this node's input needs a skipped node's
771            // output.
772            if let Some(expr) = &node.input {
773                if references_any(expr, &skipped) {
774                    skipped.insert(node.name.clone());
775                    if let Err(e) = ledger.write_skipped(&node.name) {
776                        usage.duration_ms = started.elapsed().as_millis() as u64;
777                        break 'run fail(
778                            error_codes::SCHEMA_VALIDATION_FAILED,
779                            format!("recording skip for node `{}` in ledger: {e}", node.name),
780                            usage,
781                        );
782                    }
783                    continue;
784                }
785            }
786
787            let node_input = match &node.input {
788                None => job.input.clone(),
789                Some(expr) => evaluate_input(expr, &outputs),
790            };
791
792            // Fan-out: this node runs once per manifest line rather than
793            // once, with each item checkpointed independently. Handled by
794            // its own helper because it is a genuinely different execution
795            // shape, not a variation on the repeat_until loop below (which
796            // it is parse-time forbidden to combine with).
797            if node.over.is_some() {
798                let collected = match run_fanout_node(
799                    &engine,
800                    cache,
801                    &backend,
802                    &job.alternates,
803                    &checks[index],
804                    node,
805                    &job.caps,
806                    job.embedder.as_ref(),
807                    &mut handles,
808                    &events,
809                    &cancel,
810                    &mut usage,
811                    started,
812                    index,
813                    total,
814                    ledger,
815                )
816                .await
817                {
818                    Ok(v) => v,
819                    Err(envelope) => break 'run envelope,
820                };
821                if let Err(e) = ledger.write_completed(&node.name, &collected) {
822                    usage.duration_ms = started.elapsed().as_millis() as u64;
823                    break 'run fail(
824                        error_codes::SCHEMA_VALIDATION_FAILED,
825                        format!(
826                            "recording checkpoint for node `{}` in ledger: {e}",
827                            node.name
828                        ),
829                        usage,
830                    );
831                }
832                outputs.insert(node.name.clone(), collected);
833                continue;
834            }
835
836            // Run the node, holding its output to the declared type and to
837            // every `accept` check, and climbing `on_fail` if it isn't
838            // accepted. The bounded `repeat_until` loop lives inside one
839            // attempt — see `Ladder`.
840            //
841            // Nothing checked a block's *actual* output against what it
842            // declared until this existed: a Script or Rust block could
843            // claim `{summary: text}` and return `{text: "..."}` and the
844            // job would still complete "successfully", silently handing
845            // whatever consumed `summary` a `null`. `matches_value` is
846            // deliberately permissive about `bytes`/`image`/`document` (see
847            // its own doc comment) so this only ever catches genuine shape
848            // mismatches, not false positives on types this protocol has no
849            // fixed JSON encoding for.
850            let job_dir = ledger.job_dir();
851            let ladder = Ladder {
852                engine: &engine,
853                embedder: job.embedder.as_ref(),
854                job_dir,
855                cache,
856                default_backend: &backend,
857                alternates: &job.alternates,
858                checks: &checks[index],
859                expected: &node.signature.output,
860                on_fail: &node.on_fail,
861                repeat_until: node.repeat_until.as_deref(),
862                max_iterations: node.max_iterations,
863                caps: &job.caps,
864                events: &events,
865                cancel: &cancel,
866                started,
867                index,
868            };
869            let result = match ladder
870                .run(
871                    &node.module_bytes,
872                    node.script.as_deref(),
873                    node_input.clone(),
874                    &mut handles,
875                    &mut usage,
876                )
877                .await
878            {
879                Ok(value) => value,
880                Err(LadderError::Fatal(envelope)) => break 'run *envelope,
881                Err(LadderError::Exhausted {
882                    reason,
883                    escalated,
884                    envelope,
885                }) => {
886                    // Record the give-up *before* failing the job. An
887                    // escalation that only exists in the returned envelope is
888                    // gone the moment the caller stops looking, which defeats
889                    // the point: the whole reason to escalate is that nobody
890                    // is watching right now.
891                    if escalated {
892                        // With the input, so the escalation is drainable:
893                        // "here is exactly what this node was handed when it
894                        // gave up" is what reproducing it requires.
895                        if let Err(e) =
896                            ledger.write_escalated(&node.name, None, &reason, Some(&node_input))
897                        {
898                            eprintln!("recording escalation for node `{}`: {e}", node.name);
899                        }
900                    }
901                    usage.duration_ms = started.elapsed().as_millis() as u64;
902                    // The block's own envelope when it has one, so a
903                    // `wasm_trap` still reads as a `wasm_trap`.
904                    break 'run match envelope {
905                        Some(e) => *e,
906                        None => fail(
907                            error_codes::SCHEMA_VALIDATION_FAILED,
908                            format!("node `{}`: {reason}", node.name),
909                            usage,
910                        ),
911                    };
912                }
913            };
914
915            // A branching node: read its `route` field and record the
916            // decision for later nodes' branch-skip check (top of this
917            // loop).
918            //
919            // Whether this node is actually a `branches` decision at all is
920            // determined from `job.exclusive_to` rather than by threading
921            // `Branches` itself into `JobSpec`: `dag::compute_branch_exclusivity`
922            // seeds an entry for every `(label, target)` pair of every declared
923            // decision, so the set of `label`s across all `exclusive_to` entries
924            // whose `decision` names this node IS exactly this decision's full,
925            // valid label set. An unmatched or missing `route` on a genuine
926            // decision node is a job failure — loud, not a silent no-op — per
927            // the design spec's "Conditional dispatch" section.
928            let valid_labels: Vec<&str> = job
929                .exclusive_to
930                .values()
931                .filter(|ex| ex.decision == node.name)
932                .map(|ex| ex.label.as_str())
933                .collect();
934            if !valid_labels.is_empty() {
935                match result.get("route").and_then(|v| v.as_str()) {
936                    Some(route) if valid_labels.contains(&route) => {
937                        route_taken.insert(node.name.clone(), route.to_string());
938                    }
939                    Some(route) => {
940                        usage.duration_ms = started.elapsed().as_millis() as u64;
941                        break 'run fail(
942                            error_codes::SCHEMA_VALIDATION_FAILED,
943                            format!(
944                                "node `{}` produced route \"{route}\", which doesn't match any \
945                                 declared label for this branches decision",
946                                node.name
947                            ),
948                            usage,
949                        );
950                    }
951                    None => {
952                        usage.duration_ms = started.elapsed().as_millis() as u64;
953                        break 'run fail(
954                            error_codes::SCHEMA_VALIDATION_FAILED,
955                            format!(
956                                "node `{}` is a branches decision but its output has no `route` field",
957                                node.name
958                            ),
959                            usage,
960                        );
961                    }
962                }
963            } else if let Some(route) = result.get("route").and_then(|v| v.as_str()) {
964                // Not a declared decision node, but its output happens to
965                // carry a route field anyway — harmless, record it
966                // defensively (no downstream node's exclusive_to can
967                // reference this decision name if it isn't a real decision,
968                // so this is inert either way, just avoids silently
969                // dropping information that happens to be present).
970                route_taken.insert(node.name.clone(), route.to_string());
971            }
972
973            // Checkpoint a genuinely-executed node's result before recording
974            // it in `outputs` — the ledger write must happen before the
975            // in-memory outputs map update so a crash between the two can't
976            // leave the ledger silently behind the in-memory state (the
977            // in-memory state doesn't survive a crash anyway, so
978            // ledger-first is the only order that matters for durability;
979            // the reverse order would just be a smaller window for the same
980            // underlying property, not correctness).
981            if let Err(e) = ledger.write_completed(&node.name, &result) {
982                usage.duration_ms = started.elapsed().as_millis() as u64;
983                break 'run fail(
984                    error_codes::SCHEMA_VALIDATION_FAILED,
985                    format!(
986                        "recording checkpoint for node `{}` in ledger: {e}",
987                        node.name
988                    ),
989                    usage,
990                );
991            }
992            outputs.insert(node.name.clone(), result);
993        }
994
995        usage.duration_ms = started.elapsed().as_millis() as u64;
996
997        // What is "the" job result for a graph? Convention: the output of the
998        // LAST node in topological order that wasn't skipped. This exactly
999        // matches today's linear-pipeline behavior in the degenerate case (a
1000        // linear chain's last node in topo order is its sole sink), and
1001        // generalizes sensibly to a branching graph (exactly one path executes,
1002        // so there's still a well-defined "last node that actually ran") and to
1003        // a fan-in graph without branches (the join/sink node is last in topo
1004        // order, since nothing depends on it).
1005        //
1006        // Known limitation: for a graph with two or more independent sinks (no
1007        // edge between them at all — `cuttlefishd`'s daemon path genuinely
1008        // accepts such graphs, unlike `cuttlefish build`, which restricts itself
1009        // to linear graphs), "last in topological order" is decided by the
1010        // topological sort's tie-break (alphabetical node name among ready
1011        // nodes), not by any deliberate semantic answer about which sink's
1012        // output should represent the job. Don't mistake that tie-break for a
1013        // considered multi-sink policy — it isn't one.
1014        let result = job
1015            .nodes
1016            .iter()
1017            .rev()
1018            .find_map(|n| outputs.get(&n.name).cloned());
1019
1020        match result {
1021            Some(value) => Envelope {
1022                status: JobStatus::Completed,
1023                result: Some(value),
1024                error: None,
1025                usage,
1026            },
1027            None => fail(
1028                error_codes::SCHEMA_VALIDATION_FAILED,
1029                "every node in this job was skipped; there is no result",
1030                usage,
1031            ),
1032        }
1033    };
1034
1035    let ledger_status = match envelope.status {
1036        JobStatus::Completed => "completed",
1037        JobStatus::Failed => "failed",
1038        JobStatus::Cancelled => "cancelled",
1039        _ => "running", // shouldn't occur — defensive only
1040    };
1041    if let Err(e) = ledger.finish(ledger_status) {
1042        // A ledger write failure at this final step doesn't invalidate the
1043        // job's already-computed, correct result — losing it here means a
1044        // wrong Interrupted-detection on next restart, not a wrong result
1045        // now. Loud enough to notice, not loud enough to discard real work
1046        // that already succeeded or failed for its own, unrelated reason.
1047        eprintln!("warning: failed to record job {ledger_status} status in ledger: {e}");
1048    }
1049
1050    // After `finish`, so the warehouse never describes a job the ledger still
1051    // calls running.
1052    if let Some(plan) = &job_warehouse {
1053        write_warehouse(plan, &warehouse_nodes, ledger, &envelope);
1054    }
1055    envelope
1056}
1057
1058/// Run one block to completion, returning what it produced.
1059///
1060/// `Err` carries a finished [`Envelope`]: a stage that fails ends the whole job,
1061/// because a later stage's input is the earlier one's output and there is
1062/// nothing sensible to feed it.
1063/// Run one fan-out node: its block once per manifest line, each item
1064/// checkpointed independently, then the results materialized for whatever
1065/// consumes them downstream.
1066///
1067/// Returns the collection record described by
1068/// [`crate::dag::fanout_collection_ty`] — deliberately not any one item's
1069/// result, since downstream consumes all of them.
1070///
1071/// # Why the ledger is authoritative and the files are a projection
1072///
1073/// Items are recorded in SQLite as they conclude, and `results.jsonl` /
1074/// `failures.jsonl` are written once at the end by reading those rows back.
1075/// Appending to the text files as items finished would be simpler, but a
1076/// crash mid-append leaves a torn final line that resume then has to
1077/// reconcile against the ledger. Projecting at the end makes that class of
1078/// bug unrepresentable: SQLite is already transactional, so let it be the
1079/// thing that's true.
1080#[allow(clippy::too_many_arguments)]
1081async fn run_fanout_node(
1082    engine: &Engine,
1083    cache: &crate::module_cache::ModuleCache,
1084    backend: &Arc<dyn InferBackend>,
1085    alternates: &Alternates,
1086    checks: &crate::accept::CompiledChecks,
1087    node: &crate::dag::CheckedNode,
1088    caps: &Capabilities,
1089    embedder: Option<&Arc<dyn InferBackend>>,
1090    handles: &mut Handles,
1091    events: &mpsc::Sender<JobEvent>,
1092    cancel: &CancellationToken,
1093    usage: &mut Usage,
1094    started: Instant,
1095    index: usize,
1096    total: usize,
1097    ledger: &crate::ledger::Ledger,
1098) -> Result<serde_json::Value, Envelope> {
1099    use sha2::{Digest, Sha256};
1100
1101    let manifest_path = node
1102        .over
1103        .as_ref()
1104        .expect("run_fanout_node is only called for a node with `over`");
1105    let node_name = node.name.as_str();
1106    // NOT `node.signature.output` — that has already been replaced with the
1107    // collection record for downstream typing, so validating an item against
1108    // it would reject every single item.
1109    let item_output = node
1110        .item_output
1111        .as_ref()
1112        .unwrap_or(&node.signature.output)
1113        .clone();
1114
1115    let bail = |message: String, usage: &mut Usage| -> Envelope {
1116        usage.duration_ms = started.elapsed().as_millis() as u64;
1117        fail(
1118            error_codes::SCHEMA_VALIDATION_FAILED,
1119            message,
1120            usage.clone(),
1121        )
1122    };
1123
1124    // --- Read and validate the manifest up front -------------------------
1125    //
1126    // A malformed manifest is an authoring error, so it fails the whole job
1127    // before any item runs, rather than surfacing as N mysterious item
1128    // failures partway through.
1129    let bytes = std::fs::read(manifest_path).map_err(|e| {
1130        bail(
1131            format!(
1132                "node `{node_name}`: reading fan-out manifest {}: {e}",
1133                manifest_path.display()
1134            ),
1135            usage,
1136        )
1137    })?;
1138
1139    let text = String::from_utf8(bytes.clone()).map_err(|e| {
1140        bail(
1141            format!(
1142                "node `{node_name}`: fan-out manifest {} is not valid UTF-8: {e}",
1143                manifest_path.display()
1144            ),
1145            usage,
1146        )
1147    })?;
1148
1149    let mut items: Vec<serde_json::Value> = Vec::new();
1150    for (i, line) in text.lines().enumerate() {
1151        if line.trim().is_empty() {
1152            continue;
1153        }
1154        match serde_json::from_str(line) {
1155            Ok(v) => items.push(v),
1156            Err(e) => {
1157                return Err(bail(
1158                    format!(
1159                        "node `{node_name}`: fan-out manifest {} line {} is not valid JSON: {e}",
1160                        manifest_path.display(),
1161                        i + 1
1162                    ),
1163                    usage,
1164                ))
1165            }
1166        }
1167    }
1168
1169    if items.is_empty() {
1170        return Err(bail(
1171            format!(
1172                "node `{node_name}`: fan-out manifest {} is empty — zero items almost always \
1173                 means the step that produced it failed, and reducing over nothing would \
1174                 silently look like success",
1175                manifest_path.display()
1176            ),
1177            usage,
1178        ));
1179    }
1180
1181    // --- Pin this node to the manifest it ran against --------------------
1182    //
1183    // An item_index is only meaningful relative to one manifest, and no
1184    // graph fingerprint can see an edit to the manifest *file*.
1185    let digest = crate::hex::encode(Sha256::digest(&bytes));
1186    match ledger.check_or_record_manifest(node_name, &digest, items.len()) {
1187        Ok(Ok(())) => {}
1188        Ok(Err(previous)) => {
1189            return Err(bail(
1190                format!(
1191                    "node `{node_name}`: fan-out manifest {} has changed since this job first \
1192                     ran (was {previous}, now {digest}) — recorded item indices no longer refer \
1193                     to the same inputs, so resuming would pair results with the wrong items; \
1194                     re-submit the job instead",
1195                    manifest_path.display()
1196                ),
1197                usage,
1198            ))
1199        }
1200        Err(e) => {
1201            return Err(bail(
1202                format!("node `{node_name}`: recording fan-out manifest digest: {e}"),
1203                usage,
1204            ))
1205        }
1206    }
1207
1208    // --- Run each item ---------------------------------------------------
1209    let mut succeeded = 0usize;
1210    let mut failed = 0usize;
1211
1212    for (item_index, item_input) in items.iter().enumerate() {
1213        // Cancellation is checked here, not only inside run_stage: without
1214        // it a cancelled 500-item campaign would keep starting new items,
1215        // finishing only once the manifest ran out.
1216        if cancel.is_cancelled() {
1217            usage.duration_ms = started.elapsed().as_millis() as u64;
1218            return Err(Envelope {
1219                status: JobStatus::Cancelled,
1220                result: None,
1221                error: None,
1222                usage: usage.clone(),
1223            });
1224        }
1225
1226        // Resume: an item that already concluded — either way — is not run
1227        // again. An item that was merely in flight when a previous run died
1228        // left no row, so it lands here and runs now.
1229        match ledger.item_concluded(node_name, item_index) {
1230            Ok(true) => {
1231                if ledger
1232                    .get_item_completed(node_name, item_index)
1233                    .unwrap_or(None)
1234                    .is_some()
1235                {
1236                    succeeded += 1;
1237                } else {
1238                    failed += 1;
1239                }
1240                continue;
1241            }
1242            Ok(false) => {}
1243            Err(e) => {
1244                return Err(bail(
1245                    format!("node `{node_name}`: reading item {item_index} from ledger: {e}"),
1246                    usage,
1247                ))
1248            }
1249        }
1250
1251        let _ = events
1252            .send(JobEvent::Progress(serde_json::json!({
1253                "stage": index + 1,
1254                "of": total,
1255                "node": node_name,
1256                "item": item_index,
1257                "items": items.len(),
1258                "succeeded": succeeded,
1259                "failed": failed,
1260            })))
1261            .await;
1262
1263        // Each item's input must satisfy what the block declared. A single
1264        // mismatched line is a data-quality problem, not an authoring one,
1265        // so it fails that item and the run continues.
1266        if !node.signature.input.matches_value(item_input) {
1267            failed += 1;
1268            let message = format!(
1269                "item {item_index} does not match the block's declared input `{}`",
1270                node.signature.input
1271            );
1272            if let Err(e) =
1273                ledger.write_item_failed(node_name, item_index, &message, Some(item_input))
1274            {
1275                return Err(bail(
1276                    format!("node `{node_name}`: recording item {item_index} failure: {e}"),
1277                    usage,
1278                ));
1279            }
1280            continue;
1281        }
1282
1283        // The same ladder the ordinary-node path uses, per item. `expected`
1284        // is the *per-item* output, and `repeat_until` is `None` because
1285        // combining it with `over` is forbidden at parse time.
1286        let job_dir = ledger.job_dir();
1287        let ladder = Ladder {
1288            engine,
1289            embedder,
1290            job_dir,
1291            cache,
1292            default_backend: backend,
1293            alternates,
1294            checks,
1295            expected: &item_output,
1296            on_fail: &node.on_fail,
1297            repeat_until: None,
1298            max_iterations: None,
1299            caps,
1300            events,
1301            cancel,
1302            started,
1303            index,
1304        };
1305
1306        match ladder
1307            .run(
1308                &node.module_bytes,
1309                node.script.as_deref(),
1310                item_input.clone(),
1311                handles,
1312                usage,
1313            )
1314            .await
1315        {
1316            Ok(value) => {
1317                succeeded += 1;
1318                if let Err(e) =
1319                    ledger.write_item_completed(node_name, item_index, &value, Some(item_input))
1320                {
1321                    return Err(bail(
1322                        format!("node `{node_name}`: recording item {item_index} result: {e}"),
1323                        usage,
1324                    ));
1325                }
1326            }
1327            // A cancelled job must not be recorded as a per-item failure —
1328            // nothing about the item was wrong, and doing so would make the
1329            // cancellation permanent across a later resume.
1330            Err(LadderError::Fatal(envelope)) => return Err(*envelope),
1331            // A fan-out item's envelope is deliberately dropped: an item
1332            // failure is recorded as text and the *job* keeps going, so
1333            // there is nothing here for an envelope to become.
1334            Err(LadderError::Exhausted {
1335                reason, escalated, ..
1336            }) => {
1337                failed += 1;
1338                let message = format!("item {item_index} {reason}");
1339                // An escalated item is still a concluded failure — it counts
1340                // toward `failed` and lands in `failures.jsonl` like any
1341                // other. The escalation row is *additional*: it is what makes
1342                // this one findable later without re-reading every job.
1343                // Both carry the item's input, so a later drain can hand
1344                // the work back. Without it an escalation names an item
1345                // index against a manifest that may since have moved, which
1346                // is not enough to act on.
1347                let recorded = if escalated {
1348                    ledger.write_escalated(node_name, Some(item_index), &message, Some(item_input))
1349                } else {
1350                    ledger.write_item_failed(node_name, item_index, &message, Some(item_input))
1351                };
1352                if let Err(e) = recorded {
1353                    return Err(bail(
1354                        format!("node `{node_name}`: recording item {item_index} failure: {e}"),
1355                        usage,
1356                    ));
1357                }
1358            }
1359        }
1360    }
1361
1362    if succeeded == 0 {
1363        // Quote the first item's actual error. "All 500 items failed" with no
1364        // cause is a dead end, and when every item fails the same way — which
1365        // is the common case — the first one is the whole story.
1366        let first_error = ledger
1367            .concluded_items(node_name)
1368            .ok()
1369            .and_then(|items| items.into_iter().find_map(|(_, _, err)| err))
1370            .unwrap_or_else(|| "no error recorded".to_string());
1371        return Err(bail(
1372            format!(
1373                "node `{node_name}`: all {failed} fan-out item(s) failed — there is nothing for \
1374                 a downstream node to reduce over. First failure: {first_error}"
1375            ),
1376            usage,
1377        ));
1378    }
1379
1380    // --- Materialize the results from the ledger -------------------------
1381    //
1382    // Named per node: a graph may legally contain more than one fan-out
1383    // node, and flat results.jsonl / failures.jsonl would clobber.
1384    let results_dir = ledger.job_dir().join("results");
1385    std::fs::create_dir_all(&results_dir).map_err(|e| {
1386        bail(
1387            format!(
1388                "node `{node_name}`: creating {}: {e}",
1389                results_dir.display()
1390            ),
1391            usage,
1392        )
1393    })?;
1394    let results_path = results_dir.join(format!("{node_name}.results.jsonl"));
1395    let failures_path = results_dir.join(format!("{node_name}.failures.jsonl"));
1396
1397    let concluded = ledger.concluded_items(node_name).map_err(|e| {
1398        bail(
1399            format!("node `{node_name}`: reading concluded items from ledger: {e}"),
1400            usage,
1401        )
1402    })?;
1403
1404    let (mut results_out, mut failures_out) = (String::new(), String::new());
1405    for (item_index, output, error) in concluded {
1406        match (output, error) {
1407            (Some(value), _) => results_out.push_str(&format!(
1408                "{}\n",
1409                serde_json::json!({"item": item_index, "result": value})
1410            )),
1411            (None, Some(message)) => failures_out.push_str(&format!(
1412                "{}\n",
1413                serde_json::json!({"item": item_index, "error": message})
1414            )),
1415            (None, None) => {}
1416        }
1417    }
1418
1419    for (path, contents) in [
1420        (&results_path, &results_out),
1421        (&failures_path, &failures_out),
1422    ] {
1423        std::fs::write(path, contents).map_err(|e| {
1424            bail(
1425                format!("node `{node_name}`: writing {}: {e}", path.display()),
1426                usage,
1427            )
1428        })?;
1429    }
1430
1431    Ok(serde_json::json!({
1432        "results_path": results_path.to_string_lossy(),
1433        "failures_path": failures_path.to_string_lossy(),
1434        "succeeded": succeeded,
1435        "failed": failed,
1436    }))
1437}
1438
1439/// Why an attempt ladder ran out of rungs.
1440enum LadderError {
1441    /// Every rung was climbed and the work still wasn't accepted.
1442    Exhausted {
1443        /// The last thing that went wrong, verbatim — this becomes the text
1444        /// a human reads in `cuttlefish escalations`, so it has to name the
1445        /// actual failing check rather than "acceptance failed".
1446        reason: String,
1447        /// Whether the ladder ended on an explicit `escalate` rung, as
1448        /// opposed to simply running out. Only the former gets an escalation
1449        /// row: the author asked for someone to be told.
1450        escalated: bool,
1451        /// The last attempt's own envelope, when the last thing that went
1452        /// wrong was the block failing rather than a check rejecting it.
1453        ///
1454        /// Carried so the error *code* survives the ladder. Without it every
1455        /// `wasm_trap` and `capability_denied` would reach the caller
1456        /// flattened into `schema_validation_failed`, which is both wrong and
1457        /// a silent downgrade for the many nodes that declare no `on_fail` at
1458        /// all and should behave exactly as they did before ladders existed.
1459        envelope: Option<Box<Envelope>>,
1460    },
1461    /// The job as a whole must stop — cancellation, or a host-level error
1462    /// that has nothing to do with this node's output being wrong. Never
1463    /// retried, because retrying a cancellation is how a cancelled job comes
1464    /// back to life.
1465    Fatal(Box<Envelope>),
1466}
1467
1468/// Everything the ladder needs that doesn't change between attempts.
1469///
1470/// A struct rather than a dozen more parameters: `run_stage` already carries
1471/// thirteen, and the ladder wraps it from two call sites (one node, one
1472/// fan-out item) that must behave identically. Bundling the invariant part is
1473/// what makes "identically" checkable by eye.
1474struct Ladder<'a> {
1475    engine: &'a Engine,
1476    /// Serves `embed`, when the spec declared an embedding model.
1477    embedder: Option<&'a Arc<dyn InferBackend>>,
1478    /// Where a fetched URL is written. The job's own directory, so a
1479    /// download shares the job's lifetime and sits beside its results.
1480    job_dir: &'a std::path::Path,
1481    cache: &'a crate::module_cache::ModuleCache,
1482    /// The job's own backend: where the first attempt goes, and — always —
1483    /// where judges are asked. A judge run on the rerouted model would be
1484    /// grading its own work.
1485    default_backend: &'a Arc<dyn InferBackend>,
1486    alternates: &'a Alternates,
1487    checks: &'a crate::accept::CompiledChecks,
1488    /// What this attempt's output must structurally be. For a fan-out item
1489    /// this is the block's per-item output, *not* the collection the node
1490    /// presents downstream.
1491    expected: &'a cuttlefish_abi::Ty,
1492    on_fail: &'a [cuttlefish_core::graph::Rung],
1493    /// The node's bounded loop, if it has one. Held here, rather than around
1494    /// the ladder, because one *attempt* has to mean one whole run of the
1495    /// node: retrying a node that reached `max_iterations` without finishing
1496    /// means running its loop again from the top, not resuming it.
1497    repeat_until: Option<&'a str>,
1498    /// Required alongside `repeat_until`, enforced at parse time.
1499    max_iterations: Option<u32>,
1500    caps: &'a Capabilities,
1501    events: &'a mpsc::Sender<JobEvent>,
1502    cancel: &'a CancellationToken,
1503    started: Instant,
1504    index: usize,
1505}
1506
1507impl Ladder<'_> {
1508    /// Run the work, climbing `on_fail` until something is accepted or the
1509    /// ladder runs out.
1510    ///
1511    /// Nothing here writes to the ledger. That is the whole point: a value
1512    /// that is about to be retried is not a conclusion, and recording it
1513    /// would make a transient rejection permanent across a resume.
1514    #[allow(clippy::too_many_arguments)]
1515    async fn run(
1516        &self,
1517        module_bytes: &[u8],
1518        script: Option<&str>,
1519        input: serde_json::Value,
1520        handles: &mut Handles,
1521        usage: &mut Usage,
1522    ) -> Result<serde_json::Value, LadderError> {
1523        use cuttlefish_core::graph::Rung;
1524
1525        let mut backend = self.default_backend.clone();
1526        let mut rung = 0usize;
1527        let mut retries_left = 0u32;
1528
1529        'ladder: loop {
1530            let (reason, envelope) = match self
1531                .attempt(
1532                    module_bytes,
1533                    script,
1534                    input.clone(),
1535                    &backend,
1536                    handles,
1537                    usage,
1538                )
1539                .await
1540            {
1541                Ok(value) => return Ok(value),
1542                Err(LadderError::Fatal(e)) => return Err(LadderError::Fatal(e)),
1543                Err(LadderError::Exhausted {
1544                    reason, envelope, ..
1545                }) => (reason, envelope),
1546            };
1547
1548            // Decide the next move. This inner loop only ever advances
1549            // `rung`, so it cannot spin: a `Retry` sets a budget and falls
1550            // straight back to the attempt, and every other arm either
1551            // returns or re-attempts.
1552            loop {
1553                if retries_left > 0 {
1554                    retries_left -= 1;
1555                    continue 'ladder;
1556                }
1557                match self.on_fail.get(rung) {
1558                    None => {
1559                        return Err(LadderError::Exhausted {
1560                            reason,
1561                            escalated: false,
1562                            envelope,
1563                        })
1564                    }
1565                    Some(Rung::Retry(n)) => {
1566                        rung += 1;
1567                        retries_left = *n;
1568                    }
1569                    Some(Rung::Reroute(model)) => {
1570                        rung += 1;
1571                        match self.alternates.get(model) {
1572                            Some(b) => {
1573                                backend = b.clone();
1574                                continue 'ladder;
1575                            }
1576                            // Startup resolution should have caught this, so
1577                            // reaching here is a bug — but failing the node
1578                            // beats taking the daemon down mid-campaign, and
1579                            // the reason says exactly what happened.
1580                            None => {
1581                                return Err(LadderError::Exhausted {
1582                                    reason: format!(
1583                                        "reroute names model `{model}`, which was not resolved \
1584                                         at startup (after: {reason})"
1585                                    ),
1586                                    escalated: false,
1587                                    // Not the block's fault — this is an
1588                                    // authoring/resolution error, so it keeps
1589                                    // its own code rather than the last
1590                                    // attempt's.
1591                                    envelope: None,
1592                                });
1593                            }
1594                        }
1595                    }
1596                    Some(Rung::Escalate) => {
1597                        return Err(LadderError::Exhausted {
1598                            reason,
1599                            escalated: true,
1600                            envelope,
1601                        })
1602                    }
1603                }
1604            }
1605        }
1606    }
1607
1608    /// One attempt: run the block, then hold its output to the declared type
1609    /// and to every `accept` check, in that order.
1610    ///
1611    /// Ordering is deliberate and cost-driven. The type check is free, a
1612    /// schema costs a file's worth of validation, and a judge costs a whole
1613    /// inference — so structurally broken output never pays for a judge, and
1614    /// a judge is never asked to grade something it would grade incoherently.
1615    #[allow(clippy::too_many_arguments)]
1616    async fn attempt(
1617        &self,
1618        module_bytes: &[u8],
1619        script: Option<&str>,
1620        input: serde_json::Value,
1621        backend: &Arc<dyn InferBackend>,
1622        handles: &mut Handles,
1623        usage: &mut Usage,
1624    ) -> Result<serde_json::Value, LadderError> {
1625        // A check said no. The block itself is fine, so there is no envelope
1626        // to preserve.
1627        let rejected = |reason: String| LadderError::Exhausted {
1628            reason,
1629            escalated: false,
1630            envelope: None,
1631        };
1632
1633        // The bounded loop. A node without `repeat_until` takes the `None`
1634        // arm on its first pass and runs exactly once.
1635        let mut current = input.clone();
1636        let mut iterations: u32 = 0;
1637        let value = loop {
1638            let produced = match run_stage(
1639                self.engine,
1640                self.cache,
1641                backend,
1642                module_bytes,
1643                current.clone(),
1644                script,
1645                self.caps,
1646                self.embedder,
1647                self.job_dir,
1648                handles,
1649                self.events,
1650                self.cancel,
1651                usage,
1652                self.started,
1653                self.index,
1654            )
1655            .await
1656            {
1657                Ok(v) => v,
1658                // A cancelled job stops the ladder dead; anything else is the
1659                // block failing, which is exactly what a ladder exists to
1660                // survive.
1661                Err(envelope) if envelope.status == JobStatus::Cancelled => {
1662                    return Err(LadderError::Fatal(Box::new(envelope)))
1663                }
1664                Err(envelope) => {
1665                    let reason = envelope
1666                        .error
1667                        .as_ref()
1668                        .map(|e| format!("{}: {}", e.code, e.message))
1669                        .unwrap_or_else(|| "failed with no error detail".to_string());
1670                    return Err(LadderError::Exhausted {
1671                        reason,
1672                        escalated: false,
1673                        envelope: Some(Box::new(envelope)),
1674                    });
1675                }
1676            };
1677
1678            let Some(field) = self.repeat_until else {
1679                break produced;
1680            };
1681            iterations += 1;
1682            if produced.get(field).and_then(|v| v.as_str()) == Some("done") {
1683                break produced;
1684            }
1685            let max = self
1686                .max_iterations
1687                .expect("repeat_until requires max_iterations, enforced at parse time");
1688            if iterations >= max {
1689                return Err(rejected(format!(
1690                    "did not reach repeat_until=\"done\" within max_iterations={max}"
1691                )));
1692            }
1693            current = produced;
1694        };
1695
1696        if !self.expected.matches_value(&value) {
1697            return Err(rejected(format!(
1698                "produced {value}, which doesn't match the declared output `{}`",
1699                self.expected
1700            )));
1701        }
1702
1703        if let Err(why) = self.checks.check_schemas(&value) {
1704            return Err(rejected(why));
1705        }
1706
1707        match self
1708            .checks
1709            .run_judges(&input, &value, self.default_backend, self.alternates)
1710            .await
1711        {
1712            crate::accept::JudgeVerdict::Accepted => Ok(value),
1713            crate::accept::JudgeVerdict::Rejected(why) => Err(rejected(format!("judge: {why}"))),
1714            // A broken grader is worth retrying — the next attempt may get a
1715            // parseable verdict — but it is reported as what it is, so nobody
1716            // reads the escalation as "the model said no".
1717            crate::accept::JudgeVerdict::Unusable(why) => {
1718                Err(rejected(format!("judge gave no usable verdict: {why}")))
1719            }
1720        }
1721    }
1722}
1723
1724#[allow(clippy::too_many_arguments)]
1725async fn run_stage(
1726    engine: &Engine,
1727    cache: &crate::module_cache::ModuleCache,
1728    backend: &Arc<dyn InferBackend>,
1729    module_bytes: &[u8],
1730    input: serde_json::Value,
1731    script: Option<&str>,
1732    caps: &Capabilities,
1733    embedder: Option<&Arc<dyn InferBackend>>,
1734    job_dir: &std::path::Path,
1735    handles: &mut Handles,
1736    events: &mpsc::Sender<JobEvent>,
1737    cancel: &CancellationToken,
1738    usage: &mut Usage,
1739    started: Instant,
1740    stage_index: usize,
1741) -> Result<serde_json::Value, Envelope> {
1742    // Naming the stage turns "the job failed" into "the second block failed",
1743    // which is the difference between a usable error and a hunt.
1744    let blame = |message: String| -> String {
1745        if stage_index == 0 {
1746            message
1747        } else {
1748            format!("block {} of the pipeline: {message}", stage_index + 1)
1749        }
1750    };
1751    // Documents are read from their path rather than their descriptor — both
1752    // extraction and rendering want a file. Kept beside the handle table so the
1753    // two are dropped together at the end of the job.
1754    let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
1755        std::collections::HashMap::new();
1756    // Extracted text, memoized per handle for this stage. Scoped exactly as
1757    // `doc_paths` is: a handle is job-scoped, so the text keyed by it can be
1758    // too, and both are dropped together when the stage ends.
1759    let mut doc_texts: std::collections::HashMap<u32, std::sync::Arc<String>> =
1760        std::collections::HashMap::new();
1761    // Page-tree counts, memoized the same way and for the same reason.
1762    let mut doc_pages: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
1763
1764    let mut guest = match Guest::new(engine, cache, module_bytes) {
1765        Ok(g) => g,
1766        Err(e) => {
1767            return Err(fail(
1768                error_codes::WASM_TRAP,
1769                blame(e.to_string()),
1770                usage.clone(),
1771            ))
1772        }
1773    };
1774
1775    // A Script-kind stage's `module_bytes` is always the shared interpreter
1776    // (see `pipeline::resolve_and_load`), which expects its script text
1777    // wrapped alongside the real job input — the interpreter itself never
1778    // receives the raw input directly, and this is the one place per job
1779    // where that wrapping actually happens, since the script is fixed at
1780    // catalog time but the input is only known per job.
1781    let input = match script {
1782        Some(script) => serde_json::json!({
1783            "__cuttlefish_script": script,
1784            "input": input,
1785        }),
1786        None => input,
1787    };
1788
1789    let mut command = match guest.call_init(&input) {
1790        Ok(c) => c,
1791        Err(e) => {
1792            return Err(fail(
1793                error_codes::WASM_TRAP,
1794                blame(e.to_string()),
1795                usage.clone(),
1796            ))
1797        }
1798    };
1799
1800    loop {
1801        if cancel.is_cancelled() {
1802            usage.duration_ms = started.elapsed().as_millis() as u64;
1803            return Err(cancelled(usage.clone(), "job cancelled"));
1804        }
1805
1806        let event = match command {
1807            // Ends this stage, not the job: the value becomes the next
1808            // block's input, or the job's result if this was the last.
1809            Command::Done { result } => return Ok(result),
1810            Command::Fail { code, message } => {
1811                usage.duration_ms = started.elapsed().as_millis() as u64;
1812                return Err(fail(&code, blame(message), usage.clone()));
1813            }
1814            Command::Emit { progress } => {
1815                let _ = events.send(JobEvent::Progress(progress)).await;
1816                Event::Emitted
1817            }
1818
1819            // The capability check lives here, at Open, and nowhere else. Slice
1820            // takes a handle rather than a path, and handles are job-scoped, so
1821            // there is no second place a path can enter the system.
1822            Command::Embed { texts } => {
1823                let Some(backend) = embedder else {
1824                    usage.duration_ms = started.elapsed().as_millis() as u64;
1825                    return Err(fail(
1826                        error_codes::UNSUPPORTED,
1827                        "this spec declares no `embedding_model`, so `embed` has nothing to \
1828                         call. Add one, e.g. `embedding_model = Ollama \"nomic-embed-text\";` \
1829                         — it is deliberately separate from `model`, since a chat model \
1830                         cannot produce embeddings."
1831                            .to_string(),
1832                        usage.clone(),
1833                    ));
1834                };
1835                match backend.embed(&texts).await {
1836                    Ok(vectors) => Event::Embedded { vectors },
1837                    Err(e) => {
1838                        usage.duration_ms = started.elapsed().as_millis() as u64;
1839                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1840                    }
1841                }
1842            }
1843
1844            Command::Fetch { url } => {
1845                if !caps.allows_fetch(&url) {
1846                    usage.duration_ms = started.elapsed().as_millis() as u64;
1847                    let granted = if caps.fetch_prefixes().is_empty() {
1848                        "this spec grants no `Fetch` capability at all".to_string()
1849                    } else {
1850                        format!("granted prefixes: {}", caps.fetch_prefixes().join(", "))
1851                    };
1852                    return Err(fail(
1853                        error_codes::CAPABILITY_DENIED,
1854                        format!(
1855                            "fetch not permitted: {url}\nAdd `Fetch \"<prefix>\"` to the \
1856                             spec's `capabilities`. {granted}"
1857                        ),
1858                        usage.clone(),
1859                    ));
1860                }
1861                // Downloaded to the job's own directory and then opened like
1862                // any other file, so a fetched resource *is* a handle:
1863                // slice, identify, document_text and infer-with-images all
1864                // work on it with no further changes anywhere.
1865                match crate::fetch::fetch_to_file(&url, job_dir).await {
1866                    Ok(path) => match handles.open(&path) {
1867                        Ok((handle, len, kind)) => Event::Opened { handle, len, kind },
1868                        Err(e) => {
1869                            usage.duration_ms = started.elapsed().as_millis() as u64;
1870                            return Err(fail(
1871                                error_codes::UNSUPPORTED,
1872                                format!("opening the fetched copy of {url}: {e}"),
1873                                usage.clone(),
1874                            ));
1875                        }
1876                    },
1877                    Err(e) => {
1878                        usage.duration_ms = started.elapsed().as_millis() as u64;
1879                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
1880                    }
1881                }
1882            }
1883
1884            Command::Open { path } => {
1885                let p = std::path::PathBuf::from(&path);
1886                if let Some(denial) = caps.read_denial(&p) {
1887                    usage.duration_ms = started.elapsed().as_millis() as u64;
1888                    // A missing file is not a permissions problem, and saying
1889                    // so costs an iteration every time somebody's manifest
1890                    // names a file that moved.
1891                    let (code, message) = match denial {
1892                        crate::caps::ReadDenial::Missing => (
1893                            error_codes::NOT_FOUND,
1894                            format!(
1895                                "no such file: {path} — it is inside a granted root, so this is \
1896                                 the path being wrong rather than the grant"
1897                            ),
1898                        ),
1899                        crate::caps::ReadDenial::NotGranted => (
1900                            error_codes::CAPABILITY_DENIED,
1901                            format!("read not permitted: {path}"),
1902                        ),
1903                    };
1904                    return Err(fail(code, message, usage.clone()));
1905                }
1906                match handles.open(&p) {
1907                    Ok((handle, len, kind)) => {
1908                        // A PDF's page count and text layer need the whole file,
1909                        // which the handle layer deliberately does not read. Ask
1910                        // the document layer, and fall back to the plain kind if
1911                        // it cannot answer — a malformed PDF is still a file a
1912                        // block may want to read bytes from.
1913                        let kind = match kind {
1914                            cuttlefish_abi::MediaKind::Document { .. } => {
1915                                match crate::documents::inspect(&p) {
1916                                    Ok(info) => cuttlefish_abi::MediaKind::Document {
1917                                        pages: info.pages,
1918                                        has_text_layer: info.has_text_layer,
1919                                    },
1920                                    Err(_) => cuttlefish_abi::MediaKind::Binary,
1921                                }
1922                            }
1923                            other => other,
1924                        };
1925                        // Remember the path: rendering and text extraction work
1926                        // from a file, not from the open descriptor.
1927                        doc_paths.insert(handle, p.clone());
1928                        Event::Opened { handle, len, kind }
1929                    }
1930                    Err(e) => {
1931                        usage.duration_ms = started.elapsed().as_millis() as u64;
1932                        return Err(fail(
1933                            error_codes::CAPABILITY_DENIED,
1934                            e.to_string(),
1935                            usage.clone(),
1936                        ));
1937                    }
1938                }
1939            }
1940
1941            Command::Slice {
1942                handle,
1943                offset,
1944                len,
1945            } => match handles.slice(handle, offset, len) {
1946                Ok(w) => Event::Sliced {
1947                    text: w.text,
1948                    next_offset: w.next_offset,
1949                },
1950                Err(e) => {
1951                    usage.duration_ms = started.elapsed().as_millis() as u64;
1952                    return Err(fail(
1953                        error_codes::CAPABILITY_DENIED,
1954                        e.to_string(),
1955                        usage.clone(),
1956                    ));
1957                }
1958            },
1959
1960            Command::SliceBytes {
1961                handle,
1962                offset,
1963                len,
1964            } => match handles.slice_bytes(handle, offset, len) {
1965                Ok((bytes, next_offset)) => {
1966                    use base64::Engine;
1967                    Event::SlicedBytes {
1968                        bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
1969                        next_offset,
1970                    }
1971                }
1972                Err(e) => {
1973                    usage.duration_ms = started.elapsed().as_millis() as u64;
1974                    return Err(fail(
1975                        error_codes::CAPABILITY_DENIED,
1976                        e.to_string(),
1977                        usage.clone(),
1978                    ));
1979                }
1980            },
1981
1982            Command::PageText { handle, page } => {
1983                let Some(path) = doc_paths.get(&handle).cloned() else {
1984                    usage.duration_ms = started.elapsed().as_millis() as u64;
1985                    return Err(fail(
1986                        error_codes::CAPABILITY_DENIED,
1987                        format!("no such handle: {handle}"),
1988                        usage.clone(),
1989                    ));
1990                };
1991                // Extract once per handle, not once per page. The old shape
1992                // re-extracted the whole document on every call, which made
1993                // a page walk quadratic: a 342-page filing meant 342 full
1994                // extractions, and across thousands of documents that is not
1995                // slow but unrunnable.
1996                let text = match doc_text(&mut doc_texts, handle, &path) {
1997                    Ok(t) => t,
1998                    Err(e) => {
1999                        usage.duration_ms = started.elapsed().as_millis() as u64;
2000                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2001                    }
2002                };
2003                // The page-tree count is what makes the error honest: it can
2004                // say "1 addressable segment, 227 pages in the tree" rather
2005                // than blaming a scan.
2006                //
2007                // From `page_count`, never `inspect`: inspect also answers
2008                // has_text_layer, which it can only do by extracting the
2009                // whole document. Reaching for it here re-introduced the
2010                // very quadratic walk the text cache had just removed.
2011                let page_tree_count = doc_page_count(&mut doc_pages, handle, &path);
2012                match crate::documents::page_text_from(&text, page, page_tree_count) {
2013                    Ok(text) => Event::PageTexted { text },
2014                    Err(e) => {
2015                        usage.duration_ms = started.elapsed().as_millis() as u64;
2016                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2017                    }
2018                }
2019            }
2020
2021            Command::PageTextOpt { handle, page } => {
2022                // A handle naming nothing is still fatal — see
2023                // `Command::PageTextOpt`. Only failures that are properties of
2024                // the *document* degrade to a value.
2025                let Some(path) = doc_paths.get(&handle).cloned() else {
2026                    usage.duration_ms = started.elapsed().as_millis() as u64;
2027                    return Err(fail(
2028                        error_codes::CAPABILITY_DENIED,
2029                        format!("no such handle: {handle}"),
2030                        usage.clone(),
2031                    ));
2032                };
2033                // Both the whole-document extraction and the per-page lookup
2034                // can fail, and both are the corpus being what it is rather
2035                // than the script being wrong, so both become `error`.
2036                match doc_text(&mut doc_texts, handle, &path) {
2037                    Err(e) => Event::PageTextAttempted {
2038                        text: None,
2039                        error: Some(e.to_string()),
2040                    },
2041                    Ok(text) => {
2042                        let page_tree_count = doc_page_count(&mut doc_pages, handle, &path);
2043                        match crate::documents::page_text_from(&text, page, page_tree_count) {
2044                            Ok(text) => Event::PageTextAttempted {
2045                                text: Some(text),
2046                                error: None,
2047                            },
2048                            Err(e) => Event::PageTextAttempted {
2049                                text: None,
2050                                error: Some(e.to_string()),
2051                            },
2052                        }
2053                    }
2054                }
2055            }
2056
2057            Command::DocumentText { handle } => {
2058                let Some(path) = doc_paths.get(&handle).cloned() else {
2059                    usage.duration_ms = started.elapsed().as_millis() as u64;
2060                    return Err(fail(
2061                        error_codes::CAPABILITY_DENIED,
2062                        format!("no such handle: {handle}"),
2063                        usage.clone(),
2064                    ));
2065                };
2066                match doc_text(&mut doc_texts, handle, &path) {
2067                    // Cloned out of the Arc only here, at the point the text
2068                    // actually crosses to the guest.
2069                    Ok(text) => Event::PageTexted {
2070                        text: text.as_ref().clone(),
2071                    },
2072                    Err(e) => {
2073                        usage.duration_ms = started.elapsed().as_millis() as u64;
2074                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2075                    }
2076                }
2077            }
2078
2079            Command::PageImage { handle, page } => {
2080                let Some(path) = doc_paths.get(&handle).cloned() else {
2081                    usage.duration_ms = started.elapsed().as_millis() as u64;
2082                    return Err(fail(
2083                        error_codes::CAPABILITY_DENIED,
2084                        format!("no such handle: {handle}"),
2085                        usage.clone(),
2086                    ));
2087                };
2088                // A rendered page becomes a handle like any other, so it can be
2089                // named in Infer exactly as a file-backed image would be.
2090                match crate::documents::render_page(&path, page, RENDER_WIDTH) {
2091                    Ok(png) => {
2092                        let (handle, len) = handles.insert_bytes(
2093                            png,
2094                            cuttlefish_abi::MediaKind::Image {
2095                                format: "png".into(),
2096                            },
2097                        );
2098                        Event::PageImaged { handle, len }
2099                    }
2100                    Err(e) => {
2101                        usage.duration_ms = started.elapsed().as_millis() as u64;
2102                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2103                    }
2104                }
2105            }
2106
2107            Command::ImageOp { handle, op } => {
2108                // Pixels stay host-side: the guest names a handle, gets a
2109                // new handle, and never sees the bytes — same shape as
2110                // PageImage, so a transformed image is usable exactly
2111                // wherever a file-backed one is.
2112                let bytes = match handles.read_all(handle) {
2113                    Ok(b) => b,
2114                    Err(e) => {
2115                        usage.duration_ms = started.elapsed().as_millis() as u64;
2116                        return Err(fail(
2117                            error_codes::CAPABILITY_DENIED,
2118                            e.to_string(),
2119                            usage.clone(),
2120                        ));
2121                    }
2122                };
2123                match crate::images::apply(&bytes, &op) {
2124                    Ok(png) => {
2125                        let (handle, len) = handles.insert_bytes(
2126                            png,
2127                            cuttlefish_abi::MediaKind::Image {
2128                                format: "png".into(),
2129                            },
2130                        );
2131                        Event::PageImaged { handle, len }
2132                    }
2133                    Err(e) => {
2134                        usage.duration_ms = started.elapsed().as_millis() as u64;
2135                        return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2136                    }
2137                }
2138            }
2139
2140            Command::Infer {
2141                prompt,
2142                max_tokens,
2143                images,
2144            } => {
2145                // Images are named by handle; the host loads the bytes, so they
2146                // never pass through guest memory.
2147                // Refuse rather than drop. Sending images to a backend that
2148                // cannot use them produces a confident answer about nothing,
2149                // which reads as a bad model rather than a misconfigured job —
2150                // and the caller has no way to tell the difference.
2151                if !images.is_empty() && !backend.supports_images() {
2152                    usage.duration_ms = started.elapsed().as_millis() as u64;
2153                    return Err(fail(
2154                        error_codes::UNSUPPORTED,
2155                        format!(
2156                            "this job supplied {} image(s), but the backend serving `{}` cannot \
2157                             accept them. Use a vision-capable model through the `ollama` \
2158                             provider, or change the block to send text only.",
2159                            images.len(),
2160                            backend.model_name()
2161                        ),
2162                        usage.clone(),
2163                    ));
2164                }
2165                let mut image_bytes = Vec::with_capacity(images.len());
2166                for handle in &images {
2167                    match handles.read_all(*handle) {
2168                        Ok(bytes) => image_bytes.push(bytes),
2169                        Err(e) => {
2170                            usage.duration_ms = started.elapsed().as_millis() as u64;
2171                            return Err(fail(
2172                                error_codes::CAPABILITY_DENIED,
2173                                e.to_string(),
2174                                usage.clone(),
2175                            ));
2176                        }
2177                    }
2178                }
2179
2180                // Tokens must reach the guest *while* generation runs, because
2181                // the guest's Stop verdict is what ends it early. The wasmtime
2182                // Store is !Sync and cannot be touched from inside the backend's
2183                // callback, so a channel carries tokens out and a shared flag
2184                // carries the verdict back — without sharing the Store.
2185                let (tx, mut rx) = mpsc::unbounded_channel::<String>();
2186                let stop = Arc::new(AtomicBool::new(false));
2187                let sink_stop = stop.clone();
2188                let mut sink = move |t: &str| {
2189                    tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
2190                };
2191
2192                let mut trap: Option<String> = None;
2193                let outcome: Option<anyhow::Result<InferResult>> = {
2194                    let request = InferRequest {
2195                        prompt: &prompt,
2196                        max_tokens,
2197                        images: &image_bytes,
2198                    };
2199                    let infer = backend.infer(request, &mut sink);
2200                    tokio::pin!(infer);
2201                    loop {
2202                        tokio::select! {
2203                            biased;
2204                            _ = cancel.cancelled() => break None,
2205                            Some(tok) = rx.recv() => {
2206                                let _ = events.send(JobEvent::Token(tok.clone())).await;
2207                                match guest.call_on_token(&tok) {
2208                                    Ok(true) => {}
2209                                    Ok(false) => stop.store(true, Ordering::Relaxed),
2210                                    Err(e) => {
2211                                        trap = Some(e.to_string());
2212                                        break None;
2213                                    }
2214                                }
2215                            }
2216                            r = &mut infer => break Some(r),
2217                        }
2218                    }
2219                };
2220
2221                if let Some(message) = trap {
2222                    usage.duration_ms = started.elapsed().as_millis() as u64;
2223                    return Err(fail(error_codes::WASM_TRAP, message, usage.clone()));
2224                }
2225
2226                // Tokens generated in the same poll as the last one are still
2227                // queued; forward them so the stream is complete.
2228                while let Ok(tok) = rx.try_recv() {
2229                    let _ = events.send(JobEvent::Token(tok)).await;
2230                }
2231
2232                match outcome {
2233                    None => {
2234                        usage.duration_ms = started.elapsed().as_millis() as u64;
2235                        return Err(cancelled(usage.clone(), "cancelled during inference"));
2236                    }
2237                    Some(Err(e)) => {
2238                        usage.duration_ms = started.elapsed().as_millis() as u64;
2239                        return Err(fail(
2240                            error_codes::MODEL_LOAD_FAILED,
2241                            e.to_string(),
2242                            usage.clone(),
2243                        ));
2244                    }
2245                    Some(Ok(r)) => {
2246                        usage.tokens_in += r.tokens_in;
2247                        usage.tokens_out += r.tokens_out;
2248                        Event::InferDone {
2249                            text: r.text,
2250                            tokens_out: r.tokens_out,
2251                        }
2252                    }
2253                }
2254            }
2255        };
2256
2257        command = match guest.call_step(&event) {
2258            Ok(c) => c,
2259            Err(e) => {
2260                usage.duration_ms = started.elapsed().as_millis() as u64;
2261                return Err(fail(
2262                    error_codes::WASM_TRAP,
2263                    blame(e.to_string()),
2264                    usage.clone(),
2265                ));
2266            }
2267        };
2268    }
2269}
2270
2271/// Extracted text for `handle`, extracting only on the first ask.
2272///
2273/// A `String` behind an `Arc` because a large PDF's text is megabytes and
2274/// every page of a walk would otherwise clone it.
2275fn doc_text(
2276    cache: &mut std::collections::HashMap<u32, std::sync::Arc<String>>,
2277    handle: u32,
2278    path: &std::path::Path,
2279) -> anyhow::Result<std::sync::Arc<String>> {
2280    if let Some(hit) = cache.get(&handle) {
2281        return Ok(hit.clone());
2282    }
2283    let text = std::sync::Arc::new(crate::documents::document_text(path)?);
2284    cache.insert(handle, text.clone());
2285    Ok(text)
2286}
2287
2288/// The page-tree count for `handle`, read once.
2289///
2290/// Zero when the document cannot be loaded: this value exists only to make
2291/// an error message more informative, so failing to obtain it must never
2292/// turn into a second failure.
2293fn doc_page_count(
2294    cache: &mut std::collections::HashMap<u32, u32>,
2295    handle: u32,
2296    path: &std::path::Path,
2297) -> u32 {
2298    if let Some(hit) = cache.get(&handle) {
2299        return *hit;
2300    }
2301    let count = crate::documents::page_count(path).unwrap_or(0);
2302    cache.insert(handle, count);
2303    count
2304}