1use serde::Deserialize;
2use thiserror::Error;
3
4use crate::{
5 BranchSpec, BudgetSpec, CondSpec, ExpandSpec, LeafSpec, LoopUntilSpec, ModelPolicy,
6 PermissionSpec, PromotionPolicy, ReduceSpec, SequenceSpec, TeacherReviewSpec, WorkflowNode,
7 WorkflowSpec, validate_workflow_nodes,
8};
9
10pub type JavascriptWorkflowResult<T> = std::result::Result<T, JavascriptWorkflowError>;
11
12#[derive(Debug, Error)]
13pub enum JavascriptWorkflowError {
14 #[error("workflow source contains unsupported construct `{construct}`")]
15 UnsupportedConstruct { construct: &'static str },
16 #[error("workflow source did not call workflow({{...}})")]
17 MissingWorkflowCall,
18 #[error("workflow({{...}}) object could not be extracted: {0}")]
19 InvalidWorkflowObject(String),
20 #[error("invalid workflow JSON object: {0}")]
21 InvalidJson(serde_json::Error),
22 #[error("invalid workflow node: {0}")]
23 InvalidNode(String),
24}
25
26pub fn compile_javascript_workflow(
27 identifier: &str,
28 source: &str,
29) -> JavascriptWorkflowResult<WorkflowSpec> {
30 compile_js_like_workflow(identifier, source)
31}
32
33pub fn compile_typescript_workflow(
34 identifier: &str,
35 source: &str,
36) -> JavascriptWorkflowResult<WorkflowSpec> {
37 compile_js_like_workflow(identifier, source)
38}
39
40fn compile_js_like_workflow(
41 _identifier: &str,
42 source: &str,
43) -> JavascriptWorkflowResult<WorkflowSpec> {
44 reject_unsupported_constructs(source)?;
45 let object = extract_workflow_object(source)?;
46 let authored = serde_json::from_str::<JsWorkflowSpec>(object)
47 .map_err(JavascriptWorkflowError::InvalidJson)?;
48 let mut workflow = authored.into_workflow();
49 normalize_leaf_profiles(&mut workflow.nodes);
50 if workflow.goal.trim().is_empty() {
51 return Err(JavascriptWorkflowError::InvalidNode(
52 "workflow goal cannot be empty".to_string(),
53 ));
54 }
55 validate_workflow_nodes(&workflow.nodes)
56 .map_err(|error| JavascriptWorkflowError::InvalidNode(error.to_string()))?;
57 Ok(workflow)
58}
59
60fn normalize_leaf_profiles(nodes: &mut [WorkflowNode]) {
63 for node in nodes {
64 match node {
65 WorkflowNode::Leaf(spec) => {
66 if let Some(profile) = spec.profile.as_mut() {
67 *profile = profile.trim().to_lowercase();
68 }
69 }
70 WorkflowNode::BranchSet(spec) => normalize_leaf_profiles(&mut spec.children),
71 WorkflowNode::Sequence(spec) => normalize_leaf_profiles(&mut spec.children),
72 WorkflowNode::LoopUntil(spec) => normalize_leaf_profiles(&mut spec.children),
73 WorkflowNode::Cond(spec) => {
74 normalize_leaf_profiles(&mut spec.then_nodes);
75 normalize_leaf_profiles(&mut spec.else_nodes);
76 }
77 WorkflowNode::Expand(spec) => {
78 if let Some(template) = spec.template.as_deref_mut() {
79 normalize_leaf_profiles(std::slice::from_mut(template));
80 }
81 }
82 WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => {}
83 }
84 }
85}
86
87fn reject_unsupported_constructs(source: &str) -> JavascriptWorkflowResult<()> {
88 for (needle, construct) in [
89 ("import ", "import"),
90 ("import(", "dynamic import"),
91 ("require(", "require"),
92 ("fetch(", "fetch"),
93 ("XMLHttpRequest", "XMLHttpRequest"),
94 ("WebSocket", "WebSocket"),
95 ("process.", "process"),
96 ("Deno.", "Deno"),
97 ("Bun.", "Bun"),
98 ("child_process", "child_process"),
99 ("exec(", "exec"),
100 ("spawn(", "spawn"),
101 ("open(", "open"),
102 ("readFile", "readFile"),
103 ("writeFile", "writeFile"),
104 ("async ", "async"),
105 ("await ", "await"),
106 ("eval(", "eval"),
107 ("new Function", "Function"),
108 ] {
109 if source.contains(needle) {
110 return Err(JavascriptWorkflowError::UnsupportedConstruct { construct });
111 }
112 }
113 Ok(())
114}
115
116fn extract_workflow_object(source: &str) -> JavascriptWorkflowResult<&str> {
117 let workflow_pos = source
118 .find("workflow")
119 .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
120 let open_paren_rel = source[workflow_pos..]
121 .find('(')
122 .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
123 let open_paren = workflow_pos + open_paren_rel;
124 let object_start = source[open_paren + 1..]
125 .char_indices()
126 .find_map(|(idx, ch)| {
127 if ch.is_whitespace() {
128 None
129 } else {
130 Some((open_paren + 1 + idx, ch))
131 }
132 })
133 .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
134 if object_start.1 != '{' {
135 return Err(JavascriptWorkflowError::InvalidWorkflowObject(
136 "workflow(...) must receive a JSON-compatible object literal".to_string(),
137 ));
138 }
139
140 let mut depth = 0usize;
141 let mut in_string: Option<char> = None;
142 let mut escape = false;
143 for (idx, ch) in source[object_start.0..].char_indices() {
144 let absolute = object_start.0 + idx;
145 if let Some(quote) = in_string {
146 if escape {
147 escape = false;
148 } else if ch == '\\' {
149 escape = true;
150 } else if ch == quote {
151 in_string = None;
152 }
153 continue;
154 }
155
156 match ch {
157 '"' | '\'' | '`' => in_string = Some(ch),
158 '{' => depth += 1,
159 '}' => {
160 depth = depth.checked_sub(1).ok_or_else(|| {
161 JavascriptWorkflowError::InvalidWorkflowObject(
162 "unbalanced closing brace".to_string(),
163 )
164 })?;
165 if depth == 0 {
166 return Ok(&source[object_start.0..=absolute]);
167 }
168 }
169 _ => {}
170 }
171 }
172
173 Err(JavascriptWorkflowError::InvalidWorkflowObject(
174 "missing closing brace for workflow object".to_string(),
175 ))
176}
177
178#[derive(Debug, Deserialize)]
179#[serde(deny_unknown_fields)]
180struct JsWorkflowSpec {
181 #[serde(default)]
182 id: Option<String>,
183 goal: String,
184 #[serde(default)]
185 description: Option<String>,
186 #[serde(default)]
187 budget: BudgetSpec,
188 #[serde(default)]
189 permissions: PermissionSpec,
190 #[serde(default)]
191 model_policy: ModelPolicy,
192 #[serde(default)]
193 promotion_policy: PromotionPolicy,
194 #[serde(default)]
195 nodes: Vec<JsWorkflowNode>,
196}
197
198impl JsWorkflowSpec {
199 fn into_workflow(self) -> WorkflowSpec {
200 WorkflowSpec {
201 id: self.id,
202 goal: self.goal,
203 description: self.description,
204 budget: self.budget,
205 permissions: self.permissions,
206 model_policy: self.model_policy,
207 promotion_policy: self.promotion_policy,
208 nodes: self
209 .nodes
210 .into_iter()
211 .map(JsWorkflowNode::into_node)
212 .collect(),
213 }
214 }
215}
216
217#[derive(Debug, Deserialize)]
218#[serde(untagged)]
219enum JsWorkflowNode {
220 Raw(WorkflowNode),
221 Agent(JsAgentNode),
222 Branch(JsBranchNode),
223 Sequence(JsSequenceNode),
224 Reduce(JsReduceNode),
225 TeacherReview(JsTeacherReviewNode),
226 LoopUntil(JsLoopUntilNode),
227 Cond(JsCondNode),
228 Expand(JsExpandNode),
229}
230
231impl JsWorkflowNode {
232 fn into_node(self) -> WorkflowNode {
233 match self {
234 Self::Raw(node) => node,
235 Self::Agent(node) => WorkflowNode::Leaf(node.agent),
236 Self::Branch(node) => WorkflowNode::BranchSet(node.branch.into_branch()),
237 Self::Sequence(node) => WorkflowNode::Sequence(node.sequence.into_sequence()),
238 Self::Reduce(node) => WorkflowNode::Reduce(node.reduce),
239 Self::TeacherReview(node) => WorkflowNode::TeacherReview(node.teacher_review),
240 Self::LoopUntil(node) => WorkflowNode::LoopUntil(node.loop_until.into_loop_until()),
241 Self::Cond(node) => WorkflowNode::Cond(node.cond.into_cond()),
242 Self::Expand(node) => WorkflowNode::Expand(node.expand.into_expand()),
243 }
244 }
245}
246
247#[derive(Debug, Deserialize)]
248#[serde(deny_unknown_fields)]
249struct JsAgentNode {
250 agent: LeafSpec,
251}
252
253#[derive(Debug, Deserialize)]
254#[serde(deny_unknown_fields)]
255struct JsBranchNode {
256 branch: JsBranchSpec,
257}
258
259#[derive(Debug, Deserialize)]
260#[serde(deny_unknown_fields)]
261struct JsBranchSpec {
262 id: String,
263 #[serde(default)]
264 description: Option<String>,
265 #[serde(default = "default_true")]
266 parallel: bool,
267 #[serde(default)]
268 budget: BudgetSpec,
269 #[serde(default)]
270 permissions: PermissionSpec,
271 #[serde(default)]
272 model_policy: ModelPolicy,
273 #[serde(default)]
274 children: Vec<JsWorkflowNode>,
275}
276
277impl JsBranchSpec {
278 fn into_branch(self) -> BranchSpec {
279 BranchSpec {
280 id: self.id,
281 description: self.description,
282 parallel: self.parallel,
283 budget: self.budget,
284 permissions: self.permissions,
285 model_policy: self.model_policy,
286 children: self
287 .children
288 .into_iter()
289 .map(JsWorkflowNode::into_node)
290 .collect(),
291 }
292 }
293}
294
295#[derive(Debug, Deserialize)]
296#[serde(deny_unknown_fields)]
297struct JsSequenceNode {
298 sequence: JsSequenceSpec,
299}
300
301#[derive(Debug, Deserialize)]
302#[serde(deny_unknown_fields)]
303struct JsSequenceSpec {
304 id: String,
305 #[serde(default)]
306 children: Vec<JsWorkflowNode>,
307}
308
309impl JsSequenceSpec {
310 fn into_sequence(self) -> SequenceSpec {
311 SequenceSpec {
312 id: self.id,
313 children: self
314 .children
315 .into_iter()
316 .map(JsWorkflowNode::into_node)
317 .collect(),
318 }
319 }
320}
321
322#[derive(Debug, Deserialize)]
323#[serde(deny_unknown_fields)]
324struct JsReduceNode {
325 reduce: ReduceSpec,
326}
327
328#[derive(Debug, Deserialize)]
329#[serde(deny_unknown_fields)]
330struct JsTeacherReviewNode {
331 teacher_review: TeacherReviewSpec,
332}
333
334#[derive(Debug, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct JsLoopUntilNode {
337 loop_until: JsLoopUntilSpec,
338}
339
340#[derive(Debug, Deserialize)]
341#[serde(deny_unknown_fields)]
342struct JsLoopUntilSpec {
343 id: String,
344 condition: String,
345 #[serde(default)]
346 max_iterations: Option<u32>,
347 #[serde(default)]
348 children: Vec<JsWorkflowNode>,
349}
350
351impl JsLoopUntilSpec {
352 fn into_loop_until(self) -> LoopUntilSpec {
353 LoopUntilSpec {
354 id: self.id,
355 condition: self.condition,
356 max_iterations: self.max_iterations,
357 children: self
358 .children
359 .into_iter()
360 .map(JsWorkflowNode::into_node)
361 .collect(),
362 }
363 }
364}
365
366#[derive(Debug, Deserialize)]
367#[serde(deny_unknown_fields)]
368struct JsCondNode {
369 cond: JsCondSpec,
370}
371
372#[derive(Debug, Deserialize)]
373#[serde(deny_unknown_fields)]
374struct JsCondSpec {
375 id: String,
376 condition: String,
377 #[serde(default)]
378 then_nodes: Vec<JsWorkflowNode>,
379 #[serde(default)]
380 else_nodes: Vec<JsWorkflowNode>,
381}
382
383impl JsCondSpec {
384 fn into_cond(self) -> CondSpec {
385 CondSpec {
386 id: self.id,
387 condition: self.condition,
388 then_nodes: self
389 .then_nodes
390 .into_iter()
391 .map(JsWorkflowNode::into_node)
392 .collect(),
393 else_nodes: self
394 .else_nodes
395 .into_iter()
396 .map(JsWorkflowNode::into_node)
397 .collect(),
398 }
399 }
400}
401
402#[derive(Debug, Deserialize)]
403#[serde(deny_unknown_fields)]
404struct JsExpandNode {
405 expand: JsExpandSpec,
406}
407
408#[derive(Debug, Deserialize)]
409#[serde(deny_unknown_fields)]
410struct JsExpandSpec {
411 id: String,
412 source: String,
413 #[serde(default)]
414 max_children: Option<usize>,
415 #[serde(default)]
416 template: Option<Box<JsWorkflowNode>>,
417}
418
419impl JsExpandSpec {
420 fn into_expand(self) -> ExpandSpec {
421 ExpandSpec {
422 id: self.id,
423 source: self.source,
424 max_children: self.max_children,
425 template: self.template.map(|node| Box::new(node.into_node())),
426 }
427 }
428}
429
430fn default_true() -> bool {
431 true
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::{AgentType, WorkflowReplayExecutor};
438
439 #[test]
440 fn javascript_workflow_compiles_branch_reduce_to_ir() {
441 let source = r#"
442export default workflow({
443 "id": "js-audit",
444 "goal": "Audit a change with parallel agents",
445 "nodes": [
446 {
447 "branch": {
448 "id": "parallel-audit",
449 "children": [
450 {
451 "agent": {
452 "id": "docs-audit",
453 "prompt": "Inspect docs for missing updates",
454 "agent_type": "review",
455 "file_scope": ["docs"]
456 }
457 },
458 {
459 "agent": {
460 "id": "tests-audit",
461 "prompt": "Inspect targeted tests",
462 "agent_type": "verifier",
463 "budget": { "max_steps": 4 }
464 }
465 }
466 ]
467 }
468 },
469 {
470 "reduce": {
471 "id": "synthesize",
472 "inputs": ["docs-audit", "tests-audit"],
473 "prompt": "Merge the branch findings"
474 }
475 }
476 ]
477});
478"#;
479
480 let workflow =
481 compile_javascript_workflow("audit.workflow.js", source).expect("compile JS workflow");
482
483 assert_eq!(workflow.id.as_deref(), Some("js-audit"));
484 assert_eq!(workflow.nodes.len(), 2);
485 let WorkflowNode::BranchSet(branch) = &workflow.nodes[0] else {
486 panic!("first node should be a branch");
487 };
488 assert!(branch.parallel);
489 assert_eq!(branch.children.len(), 2);
490 let WorkflowNode::Leaf(leaf) = &branch.children[1] else {
491 panic!("second branch child should be a leaf");
492 };
493 assert_eq!(leaf.agent_type, AgentType::Verifier);
494 assert_eq!(leaf.budget.max_steps, Some(4));
495 assert!(matches!(workflow.nodes[1], WorkflowNode::Reduce(_)));
496 }
497
498 #[test]
499 fn typescript_workflow_allows_satisfies_suffix_without_executing_js() {
500 let source = r#"
501export default workflow({
502 "goal": "TS authored workflow",
503 "nodes": [
504 { "agent": { "id": "scan", "prompt": "scan safely" } }
505 ]
506} satisfies WorkflowSpec);
507"#;
508
509 let workflow =
510 compile_typescript_workflow("scan.workflow.ts", source).expect("compile TS workflow");
511
512 assert_eq!(workflow.goal, "TS authored workflow");
513 assert_eq!(workflow.nodes.len(), 1);
514 }
515
516 #[test]
517 fn javascript_workflow_accepts_and_normalizes_agent_profile() {
518 let source = r#"
519workflow({
520 "goal": "profile routing",
521 "nodes": [
522 { "agent": { "id": "review", "prompt": "review the diff", "profile": " Reviewer " } },
523 { "agent": { "id": "scan", "prompt": "scan safely" } }
524 ]
525});
526"#;
527
528 let workflow = compile_javascript_workflow("profile.workflow.js", source)
529 .expect("profile-carrying workflow should compile");
530
531 let WorkflowNode::Leaf(review) = &workflow.nodes[0] else {
532 panic!("first node should be a leaf");
533 };
534 assert_eq!(review.profile.as_deref(), Some("reviewer"));
535 let WorkflowNode::Leaf(scan) = &workflow.nodes[1] else {
536 panic!("second node should be a leaf");
537 };
538 assert_eq!(scan.profile, None);
539 }
540
541 #[test]
542 fn javascript_workflow_rejects_invalid_agent_profiles() {
543 for bad in [r#""""#, r#""has space""#, r#""quote\"y""#, r#""a=b""#] {
544 let source = format!(
545 r#"
546workflow({{
547 "goal": "bad profile",
548 "nodes": [
549 {{ "agent": {{ "id": "scan", "prompt": "scan safely", "profile": {bad} }} }}
550 ]
551}});
552"#
553 );
554
555 let err = compile_javascript_workflow("bad-profile.workflow.js", &source)
556 .expect_err("invalid profile should be rejected");
557
558 assert!(
559 matches!(err, JavascriptWorkflowError::InvalidNode(_)),
560 "profile {bad} should fail as an invalid node, got {err:?}"
561 );
562 assert!(err.to_string().contains("profile"));
563 }
564 }
565
566 #[test]
567 fn javascript_workflow_rejects_runtime_effects() {
568 let source = r#"
569import fs from "fs";
570workflow({ "goal": "bad", "nodes": [] });
571"#;
572
573 let err = compile_javascript_workflow("bad.workflow.js", source)
574 .expect_err("imports must be rejected");
575
576 assert!(matches!(
577 err,
578 JavascriptWorkflowError::UnsupportedConstruct {
579 construct: "import"
580 }
581 ));
582 }
583
584 #[test]
585 fn javascript_workflow_rejects_unknown_result_reference() {
586 let source = r#"
587workflow({
588 "goal": "bad dependency",
589 "nodes": [
590 {
591 "agent": {
592 "id": "scan",
593 "prompt": "scan safely",
594 "depends_on_results": ["missing"]
595 }
596 }
597 ]
598});
599"#;
600
601 let err = compile_javascript_workflow("bad-reference.workflow.js", source)
602 .expect_err("validation must reject unknown result references");
603
604 assert!(matches!(err, JavascriptWorkflowError::InvalidNode(_)));
605 assert!(err.to_string().contains("missing"));
606 }
607
608 #[test]
609 fn javascript_example_compiles_and_replays_with_mock_trace() {
610 let source = include_str!("../../../workflows/issue_audit.workflow.js");
611 let workflow =
612 compile_javascript_workflow("issue_audit.workflow.js", source).expect("compile");
613 let trace = crate::WorkflowReplayTrace {
614 trace_id: "empty".to_string(),
615 leaf_records: Vec::new(),
616 control_records: Vec::new(),
617 };
618
619 let replayed = WorkflowReplayExecutor::new(trace)
620 .run(&workflow)
621 .expect("replay executor should accept validated JS IR");
622
623 assert_eq!(replayed.status, crate::WorkflowRunStatus::ReplayDiverged);
624 }
625}