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}