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