Skip to main content

smol_workflow_engine/js_runtime/
mod.rs

1//! JavaScript runtime boundary for executing workflow modules.
2//!
3//! The boundary in this module is intentionally independent of any particular
4//! JavaScript engine. The first implementation is backed by QuickJS via
5//! [`rquickjs`], but callers should depend on these traits and types rather than
6//! directly on the engine crate.
7//!
8//! Runtime implementations own JavaScript parsing, execution, sandboxing, and
9//! the local JavaScript ↔ Rust bridge. The Rust workflow core drives the runtime
10//! as a resumable execution and handles semantic calls/requests such as log,
11//! phase, agent, and child workflow invocations.
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::BTreeMap;
16use std::time::Duration;
17
18pub mod rquickjs;
19
20/// Input for one workflow-module evaluation.
21#[derive(Debug, Clone)]
22pub struct WorkflowModuleInput {
23    /// JavaScript/ESM-like workflow source.
24    pub source: String,
25    /// Human-readable source name used in runtime diagnostics.
26    pub source_name: String,
27    /// Workflow `args` global.
28    pub args: Value,
29    /// Initial budget snapshot exposed through the `budget` global.
30    pub budget: WorkflowBudgetSnapshot,
31    /// Sandbox limits and access policy for the runtime.
32    pub sandbox: SandboxOptions,
33}
34
35impl WorkflowModuleInput {
36    pub fn new(source: impl Into<String>, source_name: impl Into<String>, args: Value) -> Self {
37        Self {
38            source: source.into(),
39            source_name: source_name.into(),
40            args,
41            budget: WorkflowBudgetSnapshot::default(),
42            sandbox: SandboxOptions::default(),
43        }
44    }
45}
46
47/// Budget values exposed through the workflow `budget` global.
48#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
49pub struct WorkflowBudgetSnapshot {
50    pub total: Option<u64>,
51    pub spent: u64,
52}
53
54/// Sandbox limits for JavaScript execution.
55#[derive(Debug, Clone)]
56pub struct SandboxOptions {
57    /// Maximum QuickJS heap size.
58    pub memory_limit_bytes: usize,
59    /// Maximum QuickJS stack size.
60    pub max_stack_size_bytes: usize,
61    /// Wall-clock timeout enforced by the QuickJS interrupt handler.
62    pub timeout: Duration,
63    /// Import policy for workflow modules.
64    pub import_policy: ImportPolicy,
65}
66
67impl Default for SandboxOptions {
68    fn default() -> Self {
69        Self {
70            // TODO: expose this as a user-configurable workflow runtime limit.
71            memory_limit_bytes: 128 * 1024 * 1024,
72            max_stack_size_bytes: 1024 * 1024,
73            timeout: Duration::from_secs(5),
74            import_policy: ImportPolicy::DenyAll,
75        }
76    }
77}
78
79/// Module import policy for workflow JavaScript.
80#[derive(Debug, Clone, Copy, Eq, PartialEq)]
81pub enum ImportPolicy {
82    /// Do not allow workflow code to import user, filesystem, package, or host
83    /// platform modules. Runtime-owned virtual modules may still be exposed by
84    /// a concrete engine implementation, such as `workflow:extra`.
85    DenyAll,
86}
87
88/// Result from evaluating a workflow module.
89#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
90pub struct WorkflowModuleOutput {
91    /// Default-exported workflow result after function invocation, if the default
92    /// export was a function.
93    pub result: Value,
94}
95
96/// Reference to a child workflow, matching the TypeScript SDK shape.
97#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
98#[serde(untagged)]
99pub enum WorkflowRef {
100    Name(String),
101    ScriptPath {
102        #[serde(rename = "scriptPath")]
103        script_path: String,
104    },
105}
106
107/// Synchronous calls emitted by workflow JS.
108///
109/// These calls do not produce JavaScript-visible values. The workflow core should
110/// update its run state and continue polling the runtime.
111#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
112#[serde(tag = "type")]
113pub enum WorkflowRuntimeCall {
114    /// Workflow called `log(...)`.
115    #[serde(rename = "log")]
116    Log { values: Vec<Value> },
117
118    /// Workflow called `phase(...)`.
119    #[serde(rename = "phase")]
120    Phase {
121        name: String,
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        options: Option<Value>,
124    },
125}
126
127/// Long-running JavaScript-visible request emitted by workflow JS.
128#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
129#[serde(tag = "type")]
130pub enum WorkflowRuntimeRequest {
131    /// Workflow called `agent(...)` and is awaiting the provider result.
132    #[serde(rename = "agent")]
133    Agent {
134        id: String,
135        prompt: String,
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        options: Option<Value>,
138    },
139
140    /// Workflow called `workflow(...)` and is awaiting a child workflow result.
141    #[serde(rename = "workflow")]
142    Workflow {
143        id: String,
144        #[serde(rename = "ref")]
145        workflow_ref: WorkflowRef,
146        #[serde(default, skip_serializing_if = "Option::is_none")]
147        args: Option<Value>,
148    },
149
150    /// Workflow called `sleep(...)` from the `workflow:extra` namespace.
151    #[serde(rename = "sleep")]
152    Sleep {
153        id: String,
154        #[serde(rename = "durationMs")]
155        duration_ms: u64,
156    },
157
158    /// Workflow called `sandbox.exec(...)` and is awaiting command output.
159    #[serde(rename = "sandbox_exec")]
160    SandboxExec {
161        id: String,
162        profile: String,
163        request: WorkflowSandboxExecRequest,
164    },
165}
166
167/// Command request for `workflow:sandbox` / `SW.sandbox.exec`.
168#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
169pub struct WorkflowSandboxExecRequest {
170    pub command: String,
171    #[serde(default)]
172    pub args: Vec<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub cwd: Option<String>,
175    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
176    pub env: BTreeMap<String, String>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub stdin: Option<String>,
179}
180
181/// Command output returned by `workflow:sandbox` / `SW.sandbox.exec`.
182#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
183pub struct WorkflowSandboxExecOutput {
184    #[serde(rename = "exitCode")]
185    pub exit_code: i32,
186    pub stdout: String,
187    pub stderr: String,
188}
189
190impl WorkflowRuntimeRequest {
191    pub fn id(&self) -> &str {
192        match self {
193            Self::Agent { id, .. }
194            | Self::Workflow { id, .. }
195            | Self::Sleep { id, .. }
196            | Self::SandboxExec { id, .. } => id,
197        }
198    }
199
200    pub fn kind(&self) -> &'static str {
201        match self {
202            Self::Agent { .. } => "agent",
203            Self::Workflow { .. } => "workflow",
204            Self::Sleep { .. } => "sleep",
205            Self::SandboxExec { .. } => "sandbox_exec",
206        }
207    }
208}
209
210/// Response used to resume a pending long-running runtime request.
211#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
212pub enum WorkflowRuntimeRequestResolution {
213    Ok(Value),
214    OkUndefined,
215    OkWithBudget {
216        value: Value,
217        budget: WorkflowBudgetSnapshot,
218    },
219    Err {
220        message: String,
221    },
222}
223
224/// Result of polling a workflow JavaScript runtime execution.
225#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
226pub enum WorkflowRuntimePoll {
227    /// Runtime emitted a synchronous call. Core should handle it and poll again.
228    Call(WorkflowRuntimeCall),
229    /// Runtime is waiting for a long-running request to be resolved.
230    Request(WorkflowRuntimeRequest),
231    /// Workflow module completed.
232    Complete(WorkflowModuleOutput),
233    /// Runtime has no work ready and is not complete.
234    Pending,
235}
236
237/// Resumable workflow JavaScript execution.
238///
239/// Runtime executions are expected to be owned by a single scheduler/actor and
240/// accessed serially. Implementations are not required to be `Send` or safe for
241/// concurrent access. The workflow coordinator should keep JavaScript-engine
242/// calls isolated from provider tasks and communicate through this polling and
243/// request-resolution boundary.
244pub trait WorkflowRuntimeExecution {
245    /// Poll the JavaScript runtime for the next call, request, completion, or
246    /// pending state.
247    fn poll(&mut self) -> anyhow::Result<WorkflowRuntimePoll>;
248
249    /// Drain all currently queued unresolved runtime requests.
250    ///
251    /// Call this after [`poll`](Self::poll) returns
252    /// [`WorkflowRuntimePoll::Request`] when the caller wants to schedule all
253    /// JavaScript-created host requests concurrently. Draining removes requests
254    /// from the runtime's event queue, but their JavaScript promises remain
255    /// pending and must later be completed with [`resolve_request`](Self::resolve_request).
256    ///
257    /// If callers repeatedly poll after a request is observed without draining
258    /// or resolving it, implementations may report the same request again.
259    fn take_pending_requests(&mut self) -> anyhow::Result<Vec<WorkflowRuntimeRequest>>;
260
261    /// Resolve or reject a previously emitted runtime request by id.
262    fn resolve_request(
263        &mut self,
264        id: &str,
265        resolution: WorkflowRuntimeRequestResolution,
266    ) -> anyhow::Result<()>;
267}
268
269/// Engine-independent JavaScript workflow runtime interface.
270pub trait WorkflowJSRuntime {
271    fn start_module(
272        &self,
273        input: WorkflowModuleInput,
274    ) -> anyhow::Result<Box<dyn WorkflowRuntimeExecution>>;
275}