1use 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
30const RENDER_WIDTH: u16 = 1024;
35
36#[derive(Debug, Clone)]
38pub enum JobEvent {
39 Token(String),
41 Progress(serde_json::Value),
43}
44
45pub struct JobSpec {
47 pub module_bytes: Vec<u8>,
49 pub input: serde_json::Value,
51 pub caps: Capabilities,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Abi {
63 W32,
65 W64,
67}
68
69impl Abi {
70 fn ptr_size(self) -> usize {
72 match self {
73 Abi::W32 => 4,
74 Abi::W64 => 8,
75 }
76 }
77}
78
79struct Guest {
80 store: Store<()>,
81 memory: Memory,
82 abi: Abi,
83 alloc: TypedFunc<u32, u32>,
84 init: TypedFunc<(u32, u32), u32>,
85 step: TypedFunc<(u32, u32), u32>,
86 on_token: Option<TypedFunc<(u32, u32), i32>>,
87}
88
89impl Guest {
90 fn new(engine: &Engine, module_bytes: &[u8]) -> anyhow::Result<Self> {
91 let module = Module::new(engine, module_bytes)?;
92
93 let linker: Linker<()> = Linker::new(engine);
98 let mut store = Store::new(engine, ());
99 let instance: Instance = linker.instantiate(&mut store, &module)?;
100
101 let memory = instance
102 .get_memory(&mut store, "memory")
103 .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
104
105 let abi = if memory.ty(&store).is_64() {
109 Abi::W64
110 } else {
111 Abi::W32
112 };
113 if abi == Abi::W64 {
114 anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
115 }
116
117 Ok(Self {
118 alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
119 init: instance.get_typed_func(&mut store, "cf_init")?,
120 step: instance.get_typed_func(&mut store, "cf_step")?,
121 on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
123 memory,
124 abi,
125 store,
126 })
127 }
128
129 fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
130 let len = bytes.len() as u32;
131 let ptr = self.alloc.call(&mut self.store, len)?;
132 self.memory.write(&mut self.store, ptr as usize, bytes)?;
133 Ok((ptr, len))
134 }
135
136 fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
141 let w = self.abi.ptr_size();
142 let mut desc = vec![0u8; 2 * w];
143 self.memory
144 .read(&mut self.store, desc_ptr as usize, &mut desc)?;
145
146 let field = |bytes: &[u8]| -> u64 {
147 match w {
148 4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
149 _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
150 }
151 };
152 let ptr = field(&desc[..w]) as usize;
153 let len = field(&desc[w..]) as usize;
154
155 let mut buf = vec![0u8; len];
156 self.memory.read(&mut self.store, ptr, &mut buf)?;
157 Ok(buf)
158 }
159
160 fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
161 let bytes = serde_json::to_vec(input)?;
162 let (ptr, len) = self.write(&bytes)?;
163 let desc = self.init.call(&mut self.store, (ptr, len))?;
164 Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
165 }
166
167 fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
168 let bytes = serde_json::to_vec(event)?;
169 let (ptr, len) = self.write(&bytes)?;
170 let desc = self.step.call(&mut self.store, (ptr, len))?;
171 Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
172 }
173
174 fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
176 let Some(f) = self.on_token.clone() else {
180 return Ok(true);
181 };
182 let (ptr, len) = self.write(token.as_bytes())?;
183 Ok(f.call(&mut self.store, (ptr, len))? == 0)
184 }
185}
186
187fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
188 Envelope {
189 status: JobStatus::Failed,
190 result: None,
191 error: Some(JobError {
192 code: code.into(),
193 message: message.into(),
194 }),
195 usage,
196 }
197}
198
199fn cancelled(usage: Usage, message: &str) -> Envelope {
200 Envelope {
201 status: JobStatus::Cancelled,
202 result: None,
203 error: Some(JobError {
204 code: error_codes::CANCELLED.into(),
205 message: message.into(),
206 }),
207 usage,
208 }
209}
210
211pub async fn run_job(
216 engine: Arc<Engine>,
217 backend: Arc<dyn InferBackend>,
218 job: JobSpec,
219 events: mpsc::Sender<JobEvent>,
220 cancel: CancellationToken,
221) -> Envelope {
222 let started = Instant::now();
223 let mut usage = Usage {
224 model: backend.model_name(),
225 ..Usage::default()
226 };
227
228 let mut handles = Handles::default();
231 let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
235 std::collections::HashMap::new();
236
237 let mut guest = match Guest::new(&engine, &job.module_bytes) {
238 Ok(g) => g,
239 Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
240 };
241
242 let mut command = match guest.call_init(&job.input) {
243 Ok(c) => c,
244 Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
245 };
246
247 loop {
248 if cancel.is_cancelled() {
249 usage.duration_ms = started.elapsed().as_millis() as u64;
250 return cancelled(usage, "job cancelled");
251 }
252
253 let event = match command {
254 Command::Done { result } => {
255 usage.duration_ms = started.elapsed().as_millis() as u64;
256 return Envelope {
257 status: JobStatus::Completed,
258 result: Some(result),
259 error: None,
260 usage,
261 };
262 }
263 Command::Fail { code, message } => {
264 usage.duration_ms = started.elapsed().as_millis() as u64;
265 return fail(&code, message, usage);
266 }
267 Command::Emit { progress } => {
268 let _ = events.send(JobEvent::Progress(progress)).await;
269 Event::Emitted
270 }
271
272 Command::Open { path } => {
276 let p = std::path::PathBuf::from(&path);
277 if !job.caps.allows_read(&p) {
278 usage.duration_ms = started.elapsed().as_millis() as u64;
279 return fail(
280 error_codes::CAPABILITY_DENIED,
281 format!("read not permitted: {path}"),
282 usage,
283 );
284 }
285 match handles.open(&p) {
286 Ok((handle, len, kind)) => {
287 let kind = match kind {
293 cuttlefish_abi::MediaKind::Document { .. } => {
294 match crate::documents::inspect(&p) {
295 Ok(info) => cuttlefish_abi::MediaKind::Document {
296 pages: info.pages,
297 has_text_layer: info.has_text_layer,
298 },
299 Err(_) => cuttlefish_abi::MediaKind::Binary,
300 }
301 }
302 other => other,
303 };
304 doc_paths.insert(handle, p.clone());
307 Event::Opened { handle, len, kind }
308 }
309 Err(e) => {
310 usage.duration_ms = started.elapsed().as_millis() as u64;
311 return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
312 }
313 }
314 }
315
316 Command::Slice {
317 handle,
318 offset,
319 len,
320 } => match handles.slice(handle, offset, len) {
321 Ok(w) => Event::Sliced {
322 text: w.text,
323 next_offset: w.next_offset,
324 },
325 Err(e) => {
326 usage.duration_ms = started.elapsed().as_millis() as u64;
327 return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
328 }
329 },
330
331 Command::SliceBytes {
332 handle,
333 offset,
334 len,
335 } => match handles.slice_bytes(handle, offset, len) {
336 Ok((bytes, next_offset)) => {
337 use base64::Engine;
338 Event::SlicedBytes {
339 bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
340 next_offset,
341 }
342 }
343 Err(e) => {
344 usage.duration_ms = started.elapsed().as_millis() as u64;
345 return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
346 }
347 },
348
349 Command::PageText { handle, page } => {
350 let Some(path) = doc_paths.get(&handle).cloned() else {
351 usage.duration_ms = started.elapsed().as_millis() as u64;
352 return fail(
353 error_codes::CAPABILITY_DENIED,
354 format!("no such handle: {handle}"),
355 usage,
356 );
357 };
358 match crate::documents::page_text(&path, page) {
359 Ok(text) => Event::PageTexted { text },
360 Err(e) => {
361 usage.duration_ms = started.elapsed().as_millis() as u64;
362 return fail(error_codes::UNSUPPORTED, e.to_string(), usage);
363 }
364 }
365 }
366
367 Command::PageImage { handle, page } => {
368 let Some(path) = doc_paths.get(&handle).cloned() else {
369 usage.duration_ms = started.elapsed().as_millis() as u64;
370 return fail(
371 error_codes::CAPABILITY_DENIED,
372 format!("no such handle: {handle}"),
373 usage,
374 );
375 };
376 match crate::documents::render_page(&path, page, RENDER_WIDTH) {
379 Ok(png) => {
380 let (handle, len) = handles.insert_bytes(
381 png,
382 cuttlefish_abi::MediaKind::Image {
383 format: "png".into(),
384 },
385 );
386 Event::PageImaged { handle, len }
387 }
388 Err(e) => {
389 usage.duration_ms = started.elapsed().as_millis() as u64;
390 return fail(error_codes::UNSUPPORTED, e.to_string(), usage);
391 }
392 }
393 }
394
395 Command::Infer {
396 prompt,
397 max_tokens,
398 images,
399 } => {
400 if !images.is_empty() && !backend.supports_images() {
407 usage.duration_ms = started.elapsed().as_millis() as u64;
408 return fail(
409 error_codes::UNSUPPORTED,
410 format!(
411 "this job supplied {} image(s), but the backend serving `{}` cannot \
412 accept them. Use a vision-capable model through the `ollama` \
413 provider, or change the block to send text only.",
414 images.len(),
415 backend.model_name()
416 ),
417 usage,
418 );
419 }
420 let mut image_bytes = Vec::with_capacity(images.len());
421 for handle in &images {
422 match handles.read_all(*handle) {
423 Ok(bytes) => image_bytes.push(bytes),
424 Err(e) => {
425 usage.duration_ms = started.elapsed().as_millis() as u64;
426 return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
427 }
428 }
429 }
430
431 let (tx, mut rx) = mpsc::unbounded_channel::<String>();
437 let stop = Arc::new(AtomicBool::new(false));
438 let sink_stop = stop.clone();
439 let mut sink = move |t: &str| {
440 tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
441 };
442
443 let mut trap: Option<String> = None;
444 let outcome: Option<anyhow::Result<InferResult>> = {
445 let request = InferRequest {
446 prompt: &prompt,
447 max_tokens,
448 images: &image_bytes,
449 };
450 let infer = backend.infer(request, &mut sink);
451 tokio::pin!(infer);
452 loop {
453 tokio::select! {
454 biased;
455 _ = cancel.cancelled() => break None,
456 Some(tok) = rx.recv() => {
457 let _ = events.send(JobEvent::Token(tok.clone())).await;
458 match guest.call_on_token(&tok) {
459 Ok(true) => {}
460 Ok(false) => stop.store(true, Ordering::Relaxed),
461 Err(e) => {
462 trap = Some(e.to_string());
463 break None;
464 }
465 }
466 }
467 r = &mut infer => break Some(r),
468 }
469 }
470 };
471
472 if let Some(message) = trap {
473 usage.duration_ms = started.elapsed().as_millis() as u64;
474 return fail(error_codes::WASM_TRAP, message, usage);
475 }
476
477 while let Ok(tok) = rx.try_recv() {
480 let _ = events.send(JobEvent::Token(tok)).await;
481 }
482
483 match outcome {
484 None => {
485 usage.duration_ms = started.elapsed().as_millis() as u64;
486 return cancelled(usage, "cancelled during inference");
487 }
488 Some(Err(e)) => {
489 usage.duration_ms = started.elapsed().as_millis() as u64;
490 return fail(error_codes::MODEL_LOAD_FAILED, e.to_string(), usage);
491 }
492 Some(Ok(r)) => {
493 usage.tokens_in += r.tokens_in;
494 usage.tokens_out += r.tokens_out;
495 Event::InferDone {
496 text: r.text,
497 tokens_out: r.tokens_out,
498 }
499 }
500 }
501 }
502 };
503
504 command = match guest.call_step(&event) {
505 Ok(c) => c,
506 Err(e) => {
507 usage.duration_ms = started.elapsed().as_millis() as u64;
508 return fail(error_codes::WASM_TRAP, e.to_string(), usage);
509 }
510 };
511 }
512}