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}