1use 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#[derive(Debug, Clone, Copy)]
54pub struct VmLimits {
55 pub memory_limit_bytes: usize,
57 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#[derive(Debug, Clone, Default)]
126pub struct WorkflowVm {
127 limits: VmLimits,
128}
129
130impl WorkflowVm {
131 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn with_limits(limits: VmLimits) -> Self {
138 Self { limits }
139 }
140
141 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 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 guard.armed = false;
209 outcome
210 }
211 Err(_) => Err(WorkflowJsError::VmTerminated(
214 "VM thread exited without reporting a result".to_string(),
215 )),
216 }
217 }
218}
219
220#[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
258struct 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 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 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
466async 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 let validator = request
493 .response_schema
494 .as_ref()
495 .map(compile_schema)
496 .transpose()?;
497
498 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 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#[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
607fn 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"#;