Skip to main content

codewhale_workflow/
js_authoring.rs

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
60// Profile names are case-insensitive roster keys; the IR stores the canonical
61// lowercase form. Invalid tokens are left as-is so validation reports them.
62fn 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}