Skip to main content

codewhale_workflow_js/
vm.rs

1//! The sandboxed QuickJS VM that executes Workflow scripts.
2//!
3//! Threading model (design §2.2): `rquickjs` contexts and every `'js` value
4//! are `!Send`, so each run gets a dedicated OS thread with its own
5//! current-thread tokio reactor. Host functions do no heavy work inline —
6//! only `Send` data (JSON strings, [`TaskRequest`]s, oneshot replies) crosses
7//! to the driver; conversion back into JS values happens on the VM thread
8//! after the await resolves.
9//!
10//! Sandbox: the context registers only standard ECMAScript intrinsics plus
11//! the Workflow globals (`task`, `parallel`, `pipeline`, `log`, `phase`,
12//! `budget`, `args`). There is no module loader, no fs/net/process access,
13//! and `Date`/`Math.random` are overridden to throw so recorded runs stay
14//! deterministic for replay.
15
16use std::cell::Cell;
17use std::env;
18use std::rc::Rc;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, OnceLock};
21
22use rquickjs::function::{Async, Func};
23use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, CaughtError, Ctx, Promise, Value};
24use serde::Deserialize;
25use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot, watch};
26
27use crate::driver::{ProgressEvent, TaskCompletion, TaskRequest, WorkflowDriver};
28use crate::error::WorkflowJsError;
29use crate::schema::{compile_schema, decode_reply};
30use crate::{PARALLEL_MAX_ITEMS, WORKFLOW_LIFETIME_CAP, normalize_profile};
31
32const DEFAULT_VM_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
33const MIN_VM_MEMORY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
34const MAX_VM_MEMORY_LIMIT_BYTES: usize = 512 * 1024 * 1024;
35const DEFAULT_VM_STACK_BYTES: usize = 1024 * 1024;
36const MIN_VM_STACK_BYTES: usize = 128 * 1024;
37const MAX_VM_STACK_BYTES: usize = 8 * 1024 * 1024;
38const DEFAULT_VM_THREAD_STACK_BYTES: usize = 2 * 1024 * 1024;
39const MIN_VM_THREAD_STACK_BYTES: usize = 512 * 1024;
40const MAX_VM_THREAD_STACK_BYTES: usize = 16 * 1024 * 1024;
41const DEFAULT_MAX_CONCURRENT_VMS: usize = 4;
42const MAX_CONCURRENT_VMS: usize = 256;
43
44const VM_MEMORY_LIMIT_MB_ENV: &str = "CODEWHALE_WORKFLOW_JS_MEMORY_LIMIT_MB";
45const VM_STACK_KB_ENV: &str = "CODEWHALE_WORKFLOW_JS_STACK_KB";
46const VM_THREAD_STACK_KB_ENV: &str = "CODEWHALE_WORKFLOW_JS_THREAD_STACK_KB";
47const VM_MAX_CONCURRENT_ENV: &str = "CODEWHALE_WORKFLOW_JS_MAX_CONCURRENT";
48
49/// Resource limits applied to the QuickJS runtime before any script runs.
50///
51/// There is deliberately no wall-clock timeout here: cancellation (dropping
52/// the run future, or the driver's cancel cascade) is the deadline mechanism.
53#[derive(Debug, Clone, Copy)]
54pub struct VmLimits {
55    /// QuickJS heap ceiling in bytes (default 32 MiB).
56    pub memory_limit_bytes: usize,
57    /// Maximum interpreter stack in bytes (default 1 MiB).
58    pub max_stack_bytes: usize,
59}
60
61impl Default for VmLimits {
62    fn default() -> Self {
63        Self::from_env()
64    }
65}
66
67impl VmLimits {
68    pub fn from_env() -> Self {
69        Self {
70            memory_limit_bytes: env_usize_bytes(
71                VM_MEMORY_LIMIT_MB_ENV,
72                1024 * 1024,
73                MIN_VM_MEMORY_LIMIT_BYTES,
74                MAX_VM_MEMORY_LIMIT_BYTES,
75                DEFAULT_VM_MEMORY_LIMIT_BYTES,
76            ),
77            max_stack_bytes: env_usize_bytes(
78                VM_STACK_KB_ENV,
79                1024,
80                MIN_VM_STACK_BYTES,
81                MAX_VM_STACK_BYTES,
82                DEFAULT_VM_STACK_BYTES,
83            ),
84        }
85    }
86}
87
88fn env_usize_bytes(name: &str, unit: usize, min: usize, max: usize, default: usize) -> usize {
89    env::var(name)
90        .ok()
91        .and_then(|raw| raw.parse::<usize>().ok())
92        .and_then(|value| value.checked_mul(unit))
93        .map(|bytes| bytes.clamp(min, max))
94        .unwrap_or(default)
95}
96
97fn max_concurrent_vms() -> usize {
98    env::var(VM_MAX_CONCURRENT_ENV)
99        .ok()
100        .and_then(|raw| raw.parse::<usize>().ok())
101        .map(|value| value.clamp(1, MAX_CONCURRENT_VMS))
102        .unwrap_or(DEFAULT_MAX_CONCURRENT_VMS)
103}
104
105fn vm_thread_stack_bytes() -> usize {
106    env_usize_bytes(
107        VM_THREAD_STACK_KB_ENV,
108        1024,
109        MIN_VM_THREAD_STACK_BYTES,
110        MAX_VM_THREAD_STACK_BYTES,
111        DEFAULT_VM_THREAD_STACK_BYTES,
112    )
113}
114
115fn vm_admission() -> &'static Arc<Semaphore> {
116    static ADMISSION: OnceLock<Arc<Semaphore>> = OnceLock::new();
117    ADMISSION.get_or_init(|| Arc::new(Semaphore::new(max_concurrent_vms())))
118}
119
120/// Executes Workflow scripts, one isolated QuickJS runtime per run.
121///
122/// Every [`WorkflowVm::run_script`] call spins up a fresh interpreter on a
123/// dedicated thread, so runs share nothing (globals, heap, interned atoms)
124/// and a wedged script can never stall a sibling run.
125#[derive(Debug, Clone, Default)]
126pub struct WorkflowVm {
127    limits: VmLimits,
128}
129
130impl WorkflowVm {
131    /// A VM with the default [`VmLimits`].
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// A VM with explicit resource limits.
137    pub fn with_limits(limits: VmLimits) -> Self {
138        Self { limits }
139    }
140
141    /// Run one Workflow script to completion.
142    ///
143    /// * `source` is the script body; it is wrapped in an async function, so
144    ///   top-level `await` and `return` both work. The returned value is the
145    ///   script's `return` value, JSON-encoded (`undefined` becomes `null`).
146    /// * `args` is exposed verbatim to the script as the `args` global.
147    /// * `driver` executes `task()` spawns and receives progress events. A
148    ///   driver instance is scoped to exactly one run: `cancel_all` is always
149    ///   invoked at run teardown (success, script error, or cancellation), so
150    ///   stray children never outlive the script that spawned them.
151    ///
152    /// Cancellation cascade (design §9): dropping the returned future cancels
153    /// the run — the interrupt handler aborts executing JS, pending `task()`
154    /// awaits resolve to errors, and `driver.cancel_all()` is invoked
155    /// immediately from the dropping thread.
156    pub async fn run_script(
157        &self,
158        source: &str,
159        args: serde_json::Value,
160        driver: Arc<dyn WorkflowDriver>,
161    ) -> Result<serde_json::Value, WorkflowJsError> {
162        let args_json = serde_json::to_string(&args)
163            .map_err(|err| WorkflowJsError::InvalidArgs(err.to_string()))?;
164        let cancel = CancelHandle::new();
165        let (result_tx, result_rx) = oneshot::channel();
166        let mut guard = RunGuard {
167            cancel: cancel.clone(),
168            driver: driver.clone(),
169            armed: true,
170        };
171
172        let permit = vm_admission()
173            .clone()
174            .acquire_owned()
175            .await
176            .map_err(|_| WorkflowJsError::VmInit("VM admission gate closed".to_string()))?;
177        let limits = self.limits;
178        let source = source.to_string();
179        let thread_driver = driver.clone();
180        let thread_cancel = cancel.clone();
181        let spawned = std::thread::Builder::new()
182            .name("workflow-js-vm".to_string())
183            .stack_size(vm_thread_stack_bytes())
184            .spawn(move || {
185                let _permit: OwnedSemaphorePermit = permit;
186                let outcome = vm_thread_main(
187                    source,
188                    args_json,
189                    thread_driver.clone(),
190                    thread_cancel,
191                    limits,
192                );
193                // Run teardown: this driver is scoped to one run, so any task
194                // still in flight is unreachable now — cancel the cascade.
195                thread_driver.cancel_all();
196                let _ = result_tx.send(outcome);
197            });
198        if let Err(err) = spawned {
199            guard.armed = false;
200            return Err(WorkflowJsError::VmInit(format!(
201                "failed to spawn VM thread: {err}"
202            )));
203        }
204
205        match result_rx.await {
206            Ok(outcome) => {
207                // The VM thread has already torn down and cancelled children.
208                guard.armed = false;
209                outcome
210            }
211            // VM thread panicked before reporting; leave the guard armed so
212            // its drop (right now, at return) cancels outstanding tasks.
213            Err(_) => Err(WorkflowJsError::VmTerminated(
214                "VM thread exited without reporting a result".to_string(),
215            )),
216        }
217    }
218}
219
220/// Cooperative cancel signal shared by the run future (guard side) and the VM
221/// thread. The atomic flag feeds the QuickJS interrupt handler (sync, called
222/// mid-bytecode); the watch channel wakes host futures parked on driver
223/// completions.
224#[derive(Clone)]
225struct CancelHandle {
226    flag: Arc<AtomicBool>,
227    tx: Arc<watch::Sender<bool>>,
228}
229
230impl CancelHandle {
231    fn new() -> Self {
232        let (tx, _rx) = watch::channel(false);
233        Self {
234            flag: Arc::new(AtomicBool::new(false)),
235            tx: Arc::new(tx),
236        }
237    }
238
239    fn cancel(&self) {
240        self.flag.store(true, Ordering::SeqCst);
241        self.tx.send_replace(true);
242    }
243
244    fn is_cancelled(&self) -> bool {
245        self.flag.load(Ordering::SeqCst)
246    }
247
248    async fn cancelled(&self) {
249        let mut rx = self.tx.subscribe();
250        let _ = rx.wait_for(|cancelled| *cancelled).await;
251    }
252
253    fn flag_arc(&self) -> Arc<AtomicBool> {
254        self.flag.clone()
255    }
256}
257
258/// Fires the cancel cascade if the caller drops the run future before the VM
259/// reports a result.
260struct RunGuard {
261    cancel: CancelHandle,
262    driver: Arc<dyn WorkflowDriver>,
263    armed: bool,
264}
265
266impl Drop for RunGuard {
267    fn drop(&mut self) {
268        if self.armed {
269            self.cancel.cancel();
270            self.driver.cancel_all();
271        }
272    }
273}
274
275fn vm_thread_main(
276    source: String,
277    args_json: String,
278    driver: Arc<dyn WorkflowDriver>,
279    cancel: CancelHandle,
280    limits: VmLimits,
281) -> Result<serde_json::Value, WorkflowJsError> {
282    let reactor = tokio::runtime::Builder::new_current_thread()
283        .enable_all()
284        .build()
285        .map_err(|err| WorkflowJsError::VmInit(format!("failed to build VM reactor: {err}")))?;
286    reactor.block_on(run_in_vm(source, args_json, driver, cancel, limits))
287}
288
289async fn run_in_vm(
290    source: String,
291    args_json: String,
292    driver: Arc<dyn WorkflowDriver>,
293    cancel: CancelHandle,
294    limits: VmLimits,
295) -> Result<serde_json::Value, WorkflowJsError> {
296    let runtime = AsyncRuntime::new().map_err(|err| WorkflowJsError::VmInit(err.to_string()))?;
297    runtime.set_memory_limit(limits.memory_limit_bytes).await;
298    runtime.set_max_stack_size(limits.max_stack_bytes).await;
299    let interrupt_flag = cancel.flag_arc();
300    runtime
301        .set_interrupt_handler(Some(Box::new(move || {
302            interrupt_flag.load(Ordering::Relaxed)
303        })))
304        .await;
305    let context = AsyncContext::full(&runtime)
306        .await
307        .map_err(|err| WorkflowJsError::VmInit(err.to_string()))?;
308
309    let result = context
310        .async_with(async |ctx| run_in_ctx(ctx, source, args_json, driver, cancel).await)
311        .await;
312    drop(context);
313    runtime.run_gc().await;
314    result
315}
316
317async fn run_in_ctx(
318    ctx: Ctx<'_>,
319    source: String,
320    args_json: String,
321    driver: Arc<dyn WorkflowDriver>,
322    cancel: CancelHandle,
323) -> Result<serde_json::Value, WorkflowJsError> {
324    install_host(&ctx, driver, cancel.clone(), &args_json)?;
325    ctx.eval::<(), _>(prelude())
326        .catch(&ctx)
327        .map_err(|err| WorkflowJsError::VmInit(format!("prelude failed: {err}")))?;
328
329    let wrapped = format!("(async () => {{\n{source}\n}})()");
330    let promise = ctx
331        .eval::<Promise, _>(wrapped)
332        .catch(&ctx)
333        .map_err(|err| script_error(&cancel, err))?;
334    let value = promise
335        .into_future::<Value>()
336        .await
337        .catch(&ctx)
338        .map_err(|err| script_error(&cancel, err))?;
339    js_value_to_json(&ctx, value)
340}
341
342fn script_error(cancel: &CancelHandle, err: CaughtError<'_>) -> WorkflowJsError {
343    if cancel.is_cancelled() {
344        WorkflowJsError::Cancelled
345    } else {
346        WorkflowJsError::Script(err.to_string())
347    }
348}
349
350fn js_value_to_json<'js>(
351    ctx: &Ctx<'js>,
352    value: Value<'js>,
353) -> Result<serde_json::Value, WorkflowJsError> {
354    if value.is_undefined() {
355        return Ok(serde_json::Value::Null);
356    }
357    let text = ctx
358        .json_stringify(value)
359        .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))?;
360    match text {
361        None => Ok(serde_json::Value::Null),
362        Some(text) => {
363            let text = text
364                .to_string()
365                .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))?;
366            serde_json::from_str(&text)
367                .map_err(|err| WorkflowJsError::ResultEncoding(err.to_string()))
368        }
369    }
370}
371
372fn install_host(
373    ctx: &Ctx<'_>,
374    driver: Arc<dyn WorkflowDriver>,
375    cancel: CancelHandle,
376    args_json: &str,
377) -> Result<(), WorkflowJsError> {
378    let globals = ctx.globals();
379
380    let args_value: Value = ctx
381        .json_parse(args_json)
382        .map_err(|err| WorkflowJsError::InvalidArgs(err.to_string()))?;
383    globals.set("args", args_value).map_err(init_err)?;
384
385    // Per-run lifetime counter (design §4.3): counts spawn *attempts*, and the
386    // check + increment happen with no await in between so a parallel burst
387    // cannot slip past the cap on the single-threaded VM.
388    let spawned = Rc::new(Cell::new(0u64));
389
390    let task_driver = driver.clone();
391    let task_cancel = cancel.clone();
392    globals
393        .set(
394            "__workflow_task",
395            Func::from(Async(move |opts_json: String| {
396                let driver = task_driver.clone();
397                let cancel = task_cancel.clone();
398                let spawned = spawned.clone();
399                async move { task_host(opts_json, driver, cancel, spawned).await }
400            })),
401        )
402        .map_err(init_err)?;
403
404    let log_driver = driver.clone();
405    globals
406        .set(
407            "__workflow_log",
408            Func::from(move |message: String| {
409                log_driver.progress(ProgressEvent::Log { message });
410            }),
411        )
412        .map_err(init_err)?;
413
414    let phase_driver = driver.clone();
415    globals
416        .set(
417            "__workflow_phase",
418            Func::from(move |title: String| {
419                phase_driver.progress(ProgressEvent::Phase { title });
420            }),
421        )
422        .map_err(init_err)?;
423
424    // Budget reads are live driver snapshots (design §5.2). NaN encodes
425    // "no ceiling" for `total`; the prelude maps it to `null`.
426    let total_driver = driver.clone();
427    globals
428        .set(
429            "__workflow_budget_total",
430            Func::from(move || -> f64 {
431                match total_driver.budget().total {
432                    Some(total) => total as f64,
433                    None => f64::NAN,
434                }
435            }),
436        )
437        .map_err(init_err)?;
438
439    let spent_driver = driver.clone();
440    globals
441        .set(
442            "__workflow_budget_spent",
443            Func::from(move || -> f64 { spent_driver.budget().spent as f64 }),
444        )
445        .map_err(init_err)?;
446
447    globals
448        .set(
449            "__workflow_budget_remaining",
450            Func::from(move || -> f64 {
451                match driver.budget().remaining() {
452                    Some(remaining) => remaining as f64,
453                    None => f64::INFINITY,
454                }
455            }),
456        )
457        .map_err(init_err)?;
458
459    Ok(())
460}
461
462fn init_err(err: rquickjs::Error) -> WorkflowJsError {
463    WorkflowJsError::VmInit(err.to_string())
464}
465
466/// The `task()` host call. Everything that can go wrong is reported through
467/// the JSON envelope (`{"error": ...}`) so the prelude re-throws it as a real
468/// JS `Error` with a script-side stack.
469async fn task_host(
470    opts_json: String,
471    driver: Arc<dyn WorkflowDriver>,
472    cancel: CancelHandle,
473    spawned: Rc<Cell<u64>>,
474) -> String {
475    let outcome = task_host_inner(opts_json, driver, cancel, spawned).await;
476    let envelope = match outcome {
477        Ok(value) => serde_json::json!({ "value": value }),
478        Err(message) => serde_json::json!({ "error": message }),
479    };
480    envelope.to_string()
481}
482
483async fn task_host_inner(
484    opts_json: String,
485    driver: Arc<dyn WorkflowDriver>,
486    cancel: CancelHandle,
487    spawned: Rc<Cell<u64>>,
488) -> Result<serde_json::Value, String> {
489    let request = parse_task_options(&opts_json)?;
490    // Compile the schema before spawning so a malformed one fails fast
491    // instead of burning a subagent.
492    let validator = request
493        .response_schema
494        .as_ref()
495        .map(compile_schema)
496        .transpose()?;
497
498    // Lifetime backstop (design §4.3) — checked and bumped before any await.
499    if spawned.get() >= WORKFLOW_LIFETIME_CAP {
500        return Err(format!(
501            "task(): Workflow lifetime agent cap ({WORKFLOW_LIFETIME_CAP}) reached for this run"
502        ));
503    }
504    // Fast-fail budget gate. The authoritative reservation lives in the
505    // driver (design §5.3); this only stops obviously-doomed spawns early.
506    let snapshot = driver.budget();
507    if snapshot.exhausted() {
508        return Err(format!(
509            "task(): budget exhausted ({} of {} tokens spent)",
510            snapshot.spent,
511            snapshot.total.unwrap_or(0)
512        ));
513    }
514    if cancel.is_cancelled() {
515        return Err("task(): run cancelled".to_string());
516    }
517    spawned.set(spawned.get() + 1);
518
519    let spawned_task = driver
520        .spawn_task(request)
521        .await
522        .map_err(|err| err.to_string())?;
523    let task_id = spawned_task.task_id;
524    let completion_rx = spawned_task.completion;
525    let completion = tokio::select! {
526        _ = cancel.cancelled() => return Err("task(): run cancelled".to_string()),
527        completion = completion_rx => completion
528            .map_err(|_| "task(): driver dropped the completion channel".to_string())?,
529    };
530
531    match completion {
532        TaskCompletion::Completed { text } => match &validator {
533            None => Ok(serde_json::Value::String(text)),
534            Some(validator) => match decode_reply(&text, validator) {
535                Ok(value) => Ok(value),
536                Err(message) => {
537                    driver.progress(ProgressEvent::TaskSchemaValidationFailed {
538                        task_id,
539                        message: message.clone(),
540                    });
541                    Err(message)
542                }
543            },
544        },
545        TaskCompletion::Failed { message } => Err(format!("task(): subagent failed: {message}")),
546        TaskCompletion::Cancelled => Err("task(): subagent cancelled".to_string()),
547        TaskCompletion::BudgetExhausted { message } => {
548            Err(format!("task(): budget exhausted: {message}"))
549        }
550    }
551}
552
553/// JS-facing option names for `task()` (design §3.3). Unknown fields are
554/// rejected so a typo (`responseschema`) fails loudly instead of being
555/// silently dropped.
556#[derive(Debug, Deserialize)]
557#[serde(rename_all = "camelCase", deny_unknown_fields)]
558struct TaskOptions {
559    #[serde(alias = "prompt")]
560    description: Option<String>,
561    #[serde(alias = "type")]
562    subagent_type: Option<String>,
563    profile: Option<String>,
564    model: Option<String>,
565    model_strength: Option<String>,
566    thinking: Option<String>,
567    #[serde(default)]
568    worktree: bool,
569    allowed_tools: Option<Vec<String>>,
570    max_depth: Option<u32>,
571    token_budget: Option<u64>,
572    response_schema: Option<serde_json::Value>,
573    label: Option<String>,
574    phase: Option<String>,
575}
576
577fn parse_task_options(opts_json: &str) -> Result<TaskRequest, String> {
578    let options: TaskOptions =
579        serde_json::from_str(opts_json).map_err(|err| format!("task(): invalid options: {err}"))?;
580    let description = options
581        .description
582        .filter(|description| !description.trim().is_empty())
583        .ok_or_else(|| "task(): 'description' (or 'prompt') is required".to_string())?;
584    let profile = options
585        .profile
586        .as_deref()
587        .map(normalize_profile)
588        .transpose()
589        .map_err(|err| format!("task(): {err}"))?;
590    Ok(TaskRequest {
591        description,
592        subagent_type: options.subagent_type,
593        profile,
594        model: options.model,
595        model_strength: options.model_strength,
596        thinking: options.thinking,
597        worktree: options.worktree,
598        allowed_tools: options.allowed_tools,
599        max_depth: options.max_depth,
600        token_budget: options.token_budget,
601        response_schema: options.response_schema,
602        label: options.label,
603        phase: options.phase,
604    })
605}
606
607/// The JS prelude injected before every script: determinism bans, the
608/// `task`/`parallel`/`pipeline`/`log`/`phase` stdlib (design §7), and the
609/// `budget` global.
610fn prelude() -> String {
611    PRELUDE_TEMPLATE.replace("__MAX_ITEMS__", &PARALLEL_MAX_ITEMS.to_string())
612}
613
614const PRELUDE_TEMPLATE: &str = r#""use strict";
615(() => {
616  const banned = (name) => () => {
617    throw new Error(name + " is unavailable in Workflow scripts: runs must be deterministic for record/replay");
618  };
619  const BannedDate = function Date() {
620    throw new Error("new Date()/Date() is unavailable in Workflow scripts: runs must be deterministic for record/replay");
621  };
622  BannedDate.now = banned("Date.now()");
623  BannedDate.parse = banned("Date.parse()");
624  BannedDate.UTC = banned("Date.UTC()");
625  globalThis.Date = BannedDate;
626  Math.random = banned("Math.random()");
627
628  const MAX_ITEMS = __MAX_ITEMS__;
629  const isResponseSchemaError = (err) => String(err && err.message !== undefined ? err.message : err).includes("responseSchema");
630
631  globalThis.task = async (opts) => {
632    if (opts === null || typeof opts !== "object") {
633      throw new TypeError("task(): expected an options object");
634    }
635    const envelope = JSON.parse(await __workflow_task(JSON.stringify(opts)));
636    if (envelope.error !== undefined) {
637      throw new Error(envelope.error);
638    }
639    return envelope.value;
640  };
641
642  globalThis.parallel = (thunks) => {
643    if (!Array.isArray(thunks)) {
644      throw new TypeError("parallel(): expected an array of thunks");
645    }
646    if (thunks.length > MAX_ITEMS) {
647      throw new Error("parallel(): max " + MAX_ITEMS + " items per call");
648    }
649    return Promise.all(thunks.map((thunk) => {
650      try {
651        return Promise.resolve(typeof thunk === "function" ? thunk() : thunk).catch((err) => {
652          if (isResponseSchemaError(err)) throw err;
653          __workflow_log("parallel(): dropped a failed slot as null: " + String((err && err.message) || err));
654          return null;
655        });
656      } catch (err) {
657        if (isResponseSchemaError(err)) return Promise.reject(err);
658        __workflow_log("parallel(): dropped a failed slot as null: " + String((err && err.message) || err));
659        return null;
660      }
661    }));
662  };
663
664  globalThis.pipeline = (items, ...stages) => {
665    if (!Array.isArray(items)) {
666      throw new TypeError("pipeline(): expected an array of items");
667    }
668    if (items.length > MAX_ITEMS) {
669      throw new Error("pipeline(): max " + MAX_ITEMS + " items per call");
670    }
671    return Promise.all(items.map(async (item, index) => {
672      let value = item;
673      for (const stage of stages) {
674        try {
675          value = await stage(value, item, index);
676        } catch (err) {
677          if (isResponseSchemaError(err)) throw err;
678          __workflow_log("pipeline(): dropped item " + index + " as null: " + String((err && err.message) || err));
679          return null;
680        }
681      }
682      return value;
683    }));
684  };
685
686  globalThis.log = (message) => {
687    __workflow_log(typeof message === "string" ? message : (JSON.stringify(message) ?? String(message)));
688  };
689  globalThis.phase = (title) => {
690    __workflow_phase(String(title));
691  };
692
693  const total = __workflow_budget_total();
694  globalThis.budget = Object.freeze({
695    total: Number.isNaN(total) ? null : total,
696    spent: () => __workflow_budget_spent(),
697    remaining: () => __workflow_budget_remaining(),
698  });
699})();
700"#;