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::DocumentText { handle } => {
2022 let Some(path) = doc_paths.get(&handle).cloned() else {
2023 usage.duration_ms = started.elapsed().as_millis() as u64;
2024 return Err(fail(
2025 error_codes::CAPABILITY_DENIED,
2026 format!("no such handle: {handle}"),
2027 usage.clone(),
2028 ));
2029 };
2030 match doc_text(&mut doc_texts, handle, &path) {
2031 // Cloned out of the Arc only here, at the point the text
2032 // actually crosses to the guest.
2033 Ok(text) => Event::PageTexted {
2034 text: text.as_ref().clone(),
2035 },
2036 Err(e) => {
2037 usage.duration_ms = started.elapsed().as_millis() as u64;
2038 return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2039 }
2040 }
2041 }
2042
2043 Command::PageImage { handle, page } => {
2044 let Some(path) = doc_paths.get(&handle).cloned() else {
2045 usage.duration_ms = started.elapsed().as_millis() as u64;
2046 return Err(fail(
2047 error_codes::CAPABILITY_DENIED,
2048 format!("no such handle: {handle}"),
2049 usage.clone(),
2050 ));
2051 };
2052 // A rendered page becomes a handle like any other, so it can be
2053 // named in Infer exactly as a file-backed image would be.
2054 match crate::documents::render_page(&path, page, RENDER_WIDTH) {
2055 Ok(png) => {
2056 let (handle, len) = handles.insert_bytes(
2057 png,
2058 cuttlefish_abi::MediaKind::Image {
2059 format: "png".into(),
2060 },
2061 );
2062 Event::PageImaged { handle, len }
2063 }
2064 Err(e) => {
2065 usage.duration_ms = started.elapsed().as_millis() as u64;
2066 return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2067 }
2068 }
2069 }
2070
2071 Command::ImageOp { handle, op } => {
2072 // Pixels stay host-side: the guest names a handle, gets a
2073 // new handle, and never sees the bytes — same shape as
2074 // PageImage, so a transformed image is usable exactly
2075 // wherever a file-backed one is.
2076 let bytes = match handles.read_all(handle) {
2077 Ok(b) => b,
2078 Err(e) => {
2079 usage.duration_ms = started.elapsed().as_millis() as u64;
2080 return Err(fail(
2081 error_codes::CAPABILITY_DENIED,
2082 e.to_string(),
2083 usage.clone(),
2084 ));
2085 }
2086 };
2087 match crate::images::apply(&bytes, &op) {
2088 Ok(png) => {
2089 let (handle, len) = handles.insert_bytes(
2090 png,
2091 cuttlefish_abi::MediaKind::Image {
2092 format: "png".into(),
2093 },
2094 );
2095 Event::PageImaged { handle, len }
2096 }
2097 Err(e) => {
2098 usage.duration_ms = started.elapsed().as_millis() as u64;
2099 return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
2100 }
2101 }
2102 }
2103
2104 Command::Infer {
2105 prompt,
2106 max_tokens,
2107 images,
2108 } => {
2109 // Images are named by handle; the host loads the bytes, so they
2110 // never pass through guest memory.
2111 // Refuse rather than drop. Sending images to a backend that
2112 // cannot use them produces a confident answer about nothing,
2113 // which reads as a bad model rather than a misconfigured job —
2114 // and the caller has no way to tell the difference.
2115 if !images.is_empty() && !backend.supports_images() {
2116 usage.duration_ms = started.elapsed().as_millis() as u64;
2117 return Err(fail(
2118 error_codes::UNSUPPORTED,
2119 format!(
2120 "this job supplied {} image(s), but the backend serving `{}` cannot \
2121 accept them. Use a vision-capable model through the `ollama` \
2122 provider, or change the block to send text only.",
2123 images.len(),
2124 backend.model_name()
2125 ),
2126 usage.clone(),
2127 ));
2128 }
2129 let mut image_bytes = Vec::with_capacity(images.len());
2130 for handle in &images {
2131 match handles.read_all(*handle) {
2132 Ok(bytes) => image_bytes.push(bytes),
2133 Err(e) => {
2134 usage.duration_ms = started.elapsed().as_millis() as u64;
2135 return Err(fail(
2136 error_codes::CAPABILITY_DENIED,
2137 e.to_string(),
2138 usage.clone(),
2139 ));
2140 }
2141 }
2142 }
2143
2144 // Tokens must reach the guest *while* generation runs, because
2145 // the guest's Stop verdict is what ends it early. The wasmtime
2146 // Store is !Sync and cannot be touched from inside the backend's
2147 // callback, so a channel carries tokens out and a shared flag
2148 // carries the verdict back — without sharing the Store.
2149 let (tx, mut rx) = mpsc::unbounded_channel::<String>();
2150 let stop = Arc::new(AtomicBool::new(false));
2151 let sink_stop = stop.clone();
2152 let mut sink = move |t: &str| {
2153 tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
2154 };
2155
2156 let mut trap: Option<String> = None;
2157 let outcome: Option<anyhow::Result<InferResult>> = {
2158 let request = InferRequest {
2159 prompt: &prompt,
2160 max_tokens,
2161 images: &image_bytes,
2162 };
2163 let infer = backend.infer(request, &mut sink);
2164 tokio::pin!(infer);
2165 loop {
2166 tokio::select! {
2167 biased;
2168 _ = cancel.cancelled() => break None,
2169 Some(tok) = rx.recv() => {
2170 let _ = events.send(JobEvent::Token(tok.clone())).await;
2171 match guest.call_on_token(&tok) {
2172 Ok(true) => {}
2173 Ok(false) => stop.store(true, Ordering::Relaxed),
2174 Err(e) => {
2175 trap = Some(e.to_string());
2176 break None;
2177 }
2178 }
2179 }
2180 r = &mut infer => break Some(r),
2181 }
2182 }
2183 };
2184
2185 if let Some(message) = trap {
2186 usage.duration_ms = started.elapsed().as_millis() as u64;
2187 return Err(fail(error_codes::WASM_TRAP, message, usage.clone()));
2188 }
2189
2190 // Tokens generated in the same poll as the last one are still
2191 // queued; forward them so the stream is complete.
2192 while let Ok(tok) = rx.try_recv() {
2193 let _ = events.send(JobEvent::Token(tok)).await;
2194 }
2195
2196 match outcome {
2197 None => {
2198 usage.duration_ms = started.elapsed().as_millis() as u64;
2199 return Err(cancelled(usage.clone(), "cancelled during inference"));
2200 }
2201 Some(Err(e)) => {
2202 usage.duration_ms = started.elapsed().as_millis() as u64;
2203 return Err(fail(
2204 error_codes::MODEL_LOAD_FAILED,
2205 e.to_string(),
2206 usage.clone(),
2207 ));
2208 }
2209 Some(Ok(r)) => {
2210 usage.tokens_in += r.tokens_in;
2211 usage.tokens_out += r.tokens_out;
2212 Event::InferDone {
2213 text: r.text,
2214 tokens_out: r.tokens_out,
2215 }
2216 }
2217 }
2218 }
2219 };
2220
2221 command = match guest.call_step(&event) {
2222 Ok(c) => c,
2223 Err(e) => {
2224 usage.duration_ms = started.elapsed().as_millis() as u64;
2225 return Err(fail(
2226 error_codes::WASM_TRAP,
2227 blame(e.to_string()),
2228 usage.clone(),
2229 ));
2230 }
2231 };
2232 }
2233}
2234
2235/// Extracted text for `handle`, extracting only on the first ask.
2236///
2237/// A `String` behind an `Arc` because a large PDF's text is megabytes and
2238/// every page of a walk would otherwise clone it.
2239fn doc_text(
2240 cache: &mut std::collections::HashMap<u32, std::sync::Arc<String>>,
2241 handle: u32,
2242 path: &std::path::Path,
2243) -> anyhow::Result<std::sync::Arc<String>> {
2244 if let Some(hit) = cache.get(&handle) {
2245 return Ok(hit.clone());
2246 }
2247 let text = std::sync::Arc::new(crate::documents::document_text(path)?);
2248 cache.insert(handle, text.clone());
2249 Ok(text)
2250}
2251
2252/// The page-tree count for `handle`, read once.
2253///
2254/// Zero when the document cannot be loaded: this value exists only to make
2255/// an error message more informative, so failing to obtain it must never
2256/// turn into a second failure.
2257fn doc_page_count(
2258 cache: &mut std::collections::HashMap<u32, u32>,
2259 handle: u32,
2260 path: &std::path::Path,
2261) -> u32 {
2262 if let Some(hit) = cache.get(&handle) {
2263 return *hit;
2264 }
2265 let count = crate::documents::page_count(path).unwrap_or(0);
2266 cache.insert(handle, count);
2267 count
2268}