Skip to main content

codewhale_workflow/
js_authoring.rs

1use serde::Deserialize;
2use thiserror::Error;
3
4use crate::{
5    BranchSpec, BudgetSpec, CondSpec, ExpandSpec, GateSpec, 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    normalize_gate_roles(&mut workflow.gates);
51    if workflow.goal.trim().is_empty() {
52        return Err(JavascriptWorkflowError::InvalidNode(
53            "workflow goal cannot be empty".to_string(),
54        ));
55    }
56    validate_workflow_nodes(&workflow.nodes)
57        .map_err(|error| JavascriptWorkflowError::InvalidNode(error.to_string()))?;
58    Ok(workflow)
59}
60
61// Role/profile names are case-insensitive roster keys; the IR stores the
62// canonical lowercase form. Invalid tokens are left as-is so validation
63// reports them.
64fn normalize_leaf_profiles(nodes: &mut [WorkflowNode]) {
65    for node in nodes {
66        match node {
67            WorkflowNode::Leaf(spec) => {
68                if let Some(role) = spec.role.as_mut() {
69                    *role = role.trim().to_lowercase();
70                }
71                if let Some(profile) = spec.profile.as_mut() {
72                    *profile = profile.trim().to_lowercase();
73                }
74            }
75            WorkflowNode::BranchSet(spec) => normalize_leaf_profiles(&mut spec.children),
76            WorkflowNode::Sequence(spec) => normalize_leaf_profiles(&mut spec.children),
77            WorkflowNode::LoopUntil(spec) => normalize_leaf_profiles(&mut spec.children),
78            WorkflowNode::Cond(spec) => {
79                normalize_leaf_profiles(&mut spec.then_nodes);
80                normalize_leaf_profiles(&mut spec.else_nodes);
81            }
82            WorkflowNode::Expand(spec) => {
83                if let Some(template) = spec.template.as_deref_mut() {
84                    normalize_leaf_profiles(std::slice::from_mut(template));
85                }
86            }
87            WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => {}
88        }
89    }
90}
91
92fn normalize_gate_roles(gates: &mut [GateSpec]) {
93    for gate in gates {
94        gate.role = gate.role.trim().to_lowercase();
95        if let Some(blocks_role) = gate.blocks_role.as_mut() {
96            *blocks_role = blocks_role.trim().to_lowercase();
97        }
98    }
99}
100
101fn reject_unsupported_constructs(source: &str) -> JavascriptWorkflowResult<()> {
102    for (needle, construct) in [
103        ("import ", "import"),
104        ("import(", "dynamic import"),
105        ("require(", "require"),
106        ("fetch(", "fetch"),
107        ("XMLHttpRequest", "XMLHttpRequest"),
108        ("WebSocket", "WebSocket"),
109        ("process.", "process"),
110        ("Deno.", "Deno"),
111        ("Bun.", "Bun"),
112        ("child_process", "child_process"),
113        ("exec(", "exec"),
114        ("spawn(", "spawn"),
115        ("open(", "open"),
116        ("readFile", "readFile"),
117        ("writeFile", "writeFile"),
118        ("async ", "async"),
119        ("await ", "await"),
120        ("eval(", "eval"),
121        ("new Function", "Function"),
122    ] {
123        if source.contains(needle) {
124            return Err(JavascriptWorkflowError::UnsupportedConstruct { construct });
125        }
126    }
127    Ok(())
128}
129
130fn extract_workflow_object(source: &str) -> JavascriptWorkflowResult<&str> {
131    let workflow_pos = source
132        .find("workflow")
133        .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
134    let open_paren_rel = source[workflow_pos..]
135        .find('(')
136        .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
137    let open_paren = workflow_pos + open_paren_rel;
138    let object_start = source[open_paren + 1..]
139        .char_indices()
140        .find_map(|(idx, ch)| {
141            if ch.is_whitespace() {
142                None
143            } else {
144                Some((open_paren + 1 + idx, ch))
145            }
146        })
147        .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?;
148    if object_start.1 != '{' {
149        return Err(JavascriptWorkflowError::InvalidWorkflowObject(
150            "workflow(...) must receive a JSON-compatible object literal".to_string(),
151        ));
152    }
153
154    let mut depth = 0usize;
155    let mut in_string: Option<char> = None;
156    let mut escape = false;
157    for (idx, ch) in source[object_start.0..].char_indices() {
158        let absolute = object_start.0 + idx;
159        if let Some(quote) = in_string {
160            if escape {
161                escape = false;
162            } else if ch == '\\' {
163                escape = true;
164            } else if ch == quote {
165                in_string = None;
166            }
167            continue;
168        }
169
170        match ch {
171            '"' | '\'' | '`' => in_string = Some(ch),
172            '{' => depth += 1,
173            '}' => {
174                depth = depth.checked_sub(1).ok_or_else(|| {
175                    JavascriptWorkflowError::InvalidWorkflowObject(
176                        "unbalanced closing brace".to_string(),
177                    )
178                })?;
179                if depth == 0 {
180                    return Ok(&source[object_start.0..=absolute]);
181                }
182            }
183            _ => {}
184        }
185    }
186
187    Err(JavascriptWorkflowError::InvalidWorkflowObject(
188        "missing closing brace for workflow object".to_string(),
189    ))
190}
191
192#[derive(Debug, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct JsWorkflowSpec {
195    #[serde(default)]
196    id: Option<String>,
197    goal: String,
198    #[serde(default)]
199    description: Option<String>,
200    #[serde(default)]
201    budget: BudgetSpec,
202    #[serde(default)]
203    permissions: PermissionSpec,
204    #[serde(default)]
205    model_policy: ModelPolicy,
206    #[serde(default)]
207    promotion_policy: PromotionPolicy,
208    #[serde(default)]
209    gates: Vec<GateSpec>,
210    #[serde(default)]
211    nodes: Vec<JsWorkflowNode>,
212}
213
214impl JsWorkflowSpec {
215    fn into_workflow(self) -> WorkflowSpec {
216        WorkflowSpec {
217            id: self.id,
218            goal: self.goal,
219            description: self.description,
220            budget: self.budget,
221            permissions: self.permissions,
222            model_policy: self.model_policy,
223            promotion_policy: self.promotion_policy,
224            gates: self.gates,
225            nodes: self
226                .nodes
227                .into_iter()
228                .map(JsWorkflowNode::into_node)
229                .collect(),
230        }
231    }
232}
233
234#[derive(Debug, Deserialize)]
235#[serde(untagged)]
236enum JsWorkflowNode {
237    Raw(WorkflowNode),
238    Agent(JsAgentNode),
239    Branch(JsBranchNode),
240    Sequence(JsSequenceNode),
241    Reduce(JsReduceNode),
242    TeacherReview(JsTeacherReviewNode),
243    LoopUntil(JsLoopUntilNode),
244    Cond(JsCondNode),
245    Expand(JsExpandNode),
246}
247
248impl JsWorkflowNode {
249    fn into_node(self) -> WorkflowNode {
250        match self {
251            Self::Raw(node) => node,
252            Self::Agent(node) => WorkflowNode::Leaf(node.agent),
253            Self::Branch(node) => WorkflowNode::BranchSet(node.branch.into_branch()),
254            Self::Sequence(node) => WorkflowNode::Sequence(node.sequence.into_sequence()),
255            Self::Reduce(node) => WorkflowNode::Reduce(node.reduce),
256            Self::TeacherReview(node) => WorkflowNode::TeacherReview(node.teacher_review),
257            Self::LoopUntil(node) => WorkflowNode::LoopUntil(node.loop_until.into_loop_until()),
258            Self::Cond(node) => WorkflowNode::Cond(node.cond.into_cond()),
259            Self::Expand(node) => WorkflowNode::Expand(node.expand.into_expand()),
260        }
261    }
262}
263
264#[derive(Debug, Deserialize)]
265#[serde(deny_unknown_fields)]
266struct JsAgentNode {
267    agent: LeafSpec,
268}
269
270#[derive(Debug, Deserialize)]
271#[serde(deny_unknown_fields)]
272struct JsBranchNode {
273    branch: JsBranchSpec,
274}
275
276#[derive(Debug, Deserialize)]
277#[serde(deny_unknown_fields)]
278struct JsBranchSpec {
279    id: String,
280    #[serde(default)]
281    description: Option<String>,
282    #[serde(default = "default_true")]
283    parallel: bool,
284    #[serde(default)]
285    budget: BudgetSpec,
286    #[serde(default)]
287    permissions: PermissionSpec,
288    #[serde(default)]
289    model_policy: ModelPolicy,
290    #[serde(default)]
291    children: Vec<JsWorkflowNode>,
292}
293
294impl JsBranchSpec {
295    fn into_branch(self) -> BranchSpec {
296        BranchSpec {
297            id: self.id,
298            description: self.description,
299            parallel: self.parallel,
300            budget: self.budget,
301            permissions: self.permissions,
302            model_policy: self.model_policy,
303            children: self
304                .children
305                .into_iter()
306                .map(JsWorkflowNode::into_node)
307                .collect(),
308        }
309    }
310}
311
312#[derive(Debug, Deserialize)]
313#[serde(deny_unknown_fields)]
314struct JsSequenceNode {
315    sequence: JsSequenceSpec,
316}
317
318#[derive(Debug, Deserialize)]
319#[serde(deny_unknown_fields)]
320struct JsSequenceSpec {
321    id: String,
322    #[serde(default)]
323    children: Vec<JsWorkflowNode>,
324}
325
326impl JsSequenceSpec {
327    fn into_sequence(self) -> SequenceSpec {
328        SequenceSpec {
329            id: self.id,
330            children: self
331                .children
332                .into_iter()
333                .map(JsWorkflowNode::into_node)
334                .collect(),
335        }
336    }
337}
338
339#[derive(Debug, Deserialize)]
340#[serde(deny_unknown_fields)]
341struct JsReduceNode {
342    reduce: ReduceSpec,
343}
344
345#[derive(Debug, Deserialize)]
346#[serde(deny_unknown_fields)]
347struct JsTeacherReviewNode {
348    teacher_review: TeacherReviewSpec,
349}
350
351#[derive(Debug, Deserialize)]
352#[serde(deny_unknown_fields)]
353struct JsLoopUntilNode {
354    loop_until: JsLoopUntilSpec,
355}
356
357#[derive(Debug, Deserialize)]
358#[serde(deny_unknown_fields)]
359struct JsLoopUntilSpec {
360    id: String,
361    condition: String,
362    #[serde(default)]
363    max_iterations: Option<u32>,
364    #[serde(default)]
365    children: Vec<JsWorkflowNode>,
366}
367
368impl JsLoopUntilSpec {
369    fn into_loop_until(self) -> LoopUntilSpec {
370        LoopUntilSpec {
371            id: self.id,
372            condition: self.condition,
373            max_iterations: self.max_iterations,
374            children: self
375                .children
376                .into_iter()
377                .map(JsWorkflowNode::into_node)
378                .collect(),
379        }
380    }
381}
382
383#[derive(Debug, Deserialize)]
384#[serde(deny_unknown_fields)]
385struct JsCondNode {
386    cond: JsCondSpec,
387}
388
389#[derive(Debug, Deserialize)]
390#[serde(deny_unknown_fields)]
391struct JsCondSpec {
392    id: String,
393    condition: String,
394    #[serde(default)]
395    then_nodes: Vec<JsWorkflowNode>,
396    #[serde(default)]
397    else_nodes: Vec<JsWorkflowNode>,
398}
399
400impl JsCondSpec {
401    fn into_cond(self) -> CondSpec {
402        CondSpec {
403            id: self.id,
404            condition: self.condition,
405            then_nodes: self
406                .then_nodes
407                .into_iter()
408                .map(JsWorkflowNode::into_node)
409                .collect(),
410            else_nodes: self
411                .else_nodes
412                .into_iter()
413                .map(JsWorkflowNode::into_node)
414                .collect(),
415        }
416    }
417}
418
419#[derive(Debug, Deserialize)]
420#[serde(deny_unknown_fields)]
421struct JsExpandNode {
422    expand: JsExpandSpec,
423}
424
425#[derive(Debug, Deserialize)]
426#[serde(deny_unknown_fields)]
427struct JsExpandSpec {
428    id: String,
429    source: String,
430    #[serde(default)]
431    max_children: Option<usize>,
432    #[serde(default)]
433    template: Option<Box<JsWorkflowNode>>,
434}
435
436impl JsExpandSpec {
437    fn into_expand(self) -> ExpandSpec {
438        ExpandSpec {
439            id: self.id,
440            source: self.source,
441            max_children: self.max_children,
442            template: self.template.map(|node| Box::new(node.into_node())),
443        }
444    }
445}
446
447fn default_true() -> bool {
448    true
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::{
455        AgentType, GateKind, GateOn, GateOnFail, GateOutcome, GateState, LaneGateBoard, TaskMode,
456        WorkflowReplayExecutor,
457    };
458
459    #[test]
460    fn javascript_workflow_compiles_branch_reduce_to_ir() {
461        let source = r#"
462export default workflow({
463  "id": "js-audit",
464  "goal": "Audit a change with parallel agents",
465  "nodes": [
466    {
467      "branch": {
468        "id": "parallel-audit",
469        "children": [
470          {
471            "agent": {
472              "id": "docs-audit",
473              "prompt": "Inspect docs for missing updates",
474              "agent_type": "review",
475              "file_scope": ["docs"]
476            }
477          },
478          {
479            "agent": {
480              "id": "tests-audit",
481              "prompt": "Inspect targeted tests",
482              "agent_type": "verifier",
483              "budget": { "max_steps": 4 }
484            }
485          }
486        ]
487      }
488    },
489    {
490      "reduce": {
491        "id": "synthesize",
492        "inputs": ["docs-audit", "tests-audit"],
493        "prompt": "Merge the branch findings"
494      }
495    }
496  ]
497});
498"#;
499
500        let workflow =
501            compile_javascript_workflow("audit.workflow.js", source).expect("compile JS workflow");
502
503        assert_eq!(workflow.id.as_deref(), Some("js-audit"));
504        assert_eq!(workflow.nodes.len(), 2);
505        let WorkflowNode::BranchSet(branch) = &workflow.nodes[0] else {
506            panic!("first node should be a branch");
507        };
508        assert!(branch.parallel);
509        assert_eq!(branch.children.len(), 2);
510        let WorkflowNode::Leaf(leaf) = &branch.children[1] else {
511            panic!("second branch child should be a leaf");
512        };
513        assert_eq!(leaf.agent_type, AgentType::Verifier);
514        assert_eq!(leaf.budget.max_steps, Some(4));
515        assert!(matches!(workflow.nodes[1], WorkflowNode::Reduce(_)));
516    }
517
518    #[test]
519    fn typescript_workflow_allows_satisfies_suffix_without_executing_js() {
520        let source = r#"
521export default workflow({
522  "goal": "TS authored workflow",
523  "nodes": [
524    { "agent": { "id": "scan", "prompt": "scan safely" } }
525  ]
526} satisfies WorkflowSpec);
527"#;
528
529        let workflow =
530            compile_typescript_workflow("scan.workflow.ts", source).expect("compile TS workflow");
531
532        assert_eq!(workflow.goal, "TS authored workflow");
533        assert_eq!(workflow.nodes.len(), 1);
534    }
535
536    #[test]
537    fn javascript_workflow_accepts_and_normalizes_agent_profile() {
538        let source = r#"
539workflow({
540  "goal": "profile routing",
541  "nodes": [
542    { "agent": { "id": "review", "prompt": "review the diff", "profile": " Reviewer " } },
543    { "agent": { "id": "scan", "prompt": "scan safely" } }
544  ]
545});
546"#;
547
548        let workflow = compile_javascript_workflow("profile.workflow.js", source)
549            .expect("profile-carrying workflow should compile");
550
551        let WorkflowNode::Leaf(review) = &workflow.nodes[0] else {
552            panic!("first node should be a leaf");
553        };
554        assert_eq!(review.profile.as_deref(), Some("reviewer"));
555        let WorkflowNode::Leaf(scan) = &workflow.nodes[1] else {
556            panic!("second node should be a leaf");
557        };
558        assert_eq!(scan.profile, None);
559    }
560
561    #[test]
562    fn javascript_workflow_accepts_and_normalizes_agent_role() {
563        let source = r#"
564workflow({
565  "goal": "role routing",
566  "nodes": [
567    { "agent": { "id": "scout-issue", "prompt": "Investigate #4090. Read-only.", "role": " Scout " } },
568    { "agent": { "id": "fix-it", "prompt": "Apply minimal fix.", "role": "implementer" } }
569  ]
570});
571"#;
572
573        let workflow = compile_javascript_workflow("role.workflow.js", source)
574            .expect("role-carrying workflow should compile");
575
576        let WorkflowNode::Leaf(scout) = &workflow.nodes[0] else {
577            panic!("first node should be a leaf");
578        };
579        assert_eq!(scout.role.as_deref(), Some("scout"));
580        assert_eq!(scout.profile, None);
581        // Provider/model are not required identity fields on role steps.
582        assert_eq!(scout.model_policy.provider, None);
583        assert_eq!(scout.model_policy.model, None);
584
585        let WorkflowNode::Leaf(fix) = &workflow.nodes[1] else {
586            panic!("second node should be a leaf");
587        };
588        assert_eq!(fix.role.as_deref(), Some("implementer"));
589    }
590
591    #[test]
592    fn javascript_workflow_accepts_gate_specs() {
593        let source = r#"
594workflow({
595  "goal": "role gates",
596  "gates": [
597    {
598      "id": "scout-findings",
599      "role": " Scout ",
600      "on": "role_complete",
601      "gate": "approve",
602      "on_fail": "block",
603      "blocks_role": " Implementer ",
604      "artifact_kind": "findings"
605    }
606  ],
607  "nodes": [
608    { "agent": { "id": "scout", "prompt": "Find risk.", "role": "scout" } },
609    { "agent": { "id": "fix", "prompt": "Use findings.", "role": "implementer" } }
610  ]
611});
612"#;
613
614        let workflow =
615            compile_javascript_workflow("gates.workflow.js", source).expect("compile gates");
616
617        assert_eq!(workflow.gates.len(), 1);
618        let gate = &workflow.gates[0];
619        assert_eq!(gate.id, "scout-findings");
620        assert_eq!(gate.role, "scout");
621        assert_eq!(gate.on, GateOn::RoleComplete);
622        assert_eq!(gate.gate, GateKind::Approve);
623        assert_eq!(gate.on_fail, GateOnFail::Block);
624        assert_eq!(gate.blocks_role.as_deref(), Some("implementer"));
625        assert_eq!(gate.artifact_kind.as_deref(), Some("findings"));
626    }
627
628    #[test]
629    fn stopship_acceptance_fixture_is_read_only_and_gate_complete() {
630        let source = include_str!("../../../workflows/stopship.workflow.js");
631        let workflow = compile_javascript_workflow("stopship.workflow.js", source)
632            .expect("compile stopship acceptance fixture");
633
634        assert_eq!(workflow.id.as_deref(), Some("stopship-release-acceptance"));
635        let WorkflowNode::Sequence(sequence) = &workflow.nodes[0] else {
636            panic!("acceptance fixture should begin with one ordered role chain");
637        };
638        let expected_children = [
639            ("scout", 6, 480, 96_000),
640            ("implementer", 4, 420, 72_000),
641            ("reviewer", 4, 420, 72_000),
642            ("verifier", 4, 420, 72_000),
643            ("release_lead", 3, 300, 48_000),
644        ];
645        let mut aggregate_token_cap = 0_u64;
646        assert_eq!(sequence.children.len(), expected_children.len());
647        for (node, (expected_role, max_steps, timeout_secs, max_tokens)) in
648            sequence.children.iter().zip(expected_children)
649        {
650            let WorkflowNode::Leaf(leaf) = node else {
651                panic!("acceptance role chain must contain only agent leaves");
652            };
653            assert_eq!(leaf.role.as_deref(), Some(expected_role));
654            assert_eq!(leaf.mode, TaskMode::ReadOnly);
655            assert!(!leaf.permissions.allow_write);
656            assert!(leaf.permissions.allowed_tools.is_empty());
657            assert_eq!(
658                leaf.permissions.deny_all_tools,
659                expected_role != "scout",
660                "only the source-gathering scout should receive tools"
661            );
662            assert!(
663                leaf.prompt.contains(
664                    "first non-empty line of your response must be exactly APPROVE or exactly BLOCK"
665                ),
666                "{expected_role} must declare the host-readable verdict contract"
667            );
668            assert!(
669                leaf.prompt
670                    .contains("Do not put any words before that verdict")
671                    && leaf.prompt.contains("Here is the verdict"),
672                "{expected_role} must reject verdict preambles that the host cannot parse"
673            );
674            if expected_role == "scout" {
675                assert!(
676                    leaf.prompt.contains("exactly one `grep_files` call")
677                        && leaf
678                            .prompt
679                            .contains("Do not call `grep_files` more than once")
680                        && leaf.prompt.contains("do not call `read_file`"),
681                    "the scout must finish discovery in one bounded tool round"
682                );
683                assert_eq!(
684                    leaf.file_scope
685                        .iter()
686                        .map(String::as_str)
687                        .collect::<Vec<_>>(),
688                    vec![
689                        "fleets/stopship.toml",
690                        "crates/cli/src/lib.rs",
691                        "crates/workflow/src/role_resolve.rs",
692                        "crates/tui/src/tools/workflow.rs",
693                        "crates/lane/src/runtime.rs",
694                    ],
695                    "the scout grep must not include its own authored prompt"
696                );
697                assert!(
698                    leaf.prompt.contains(
699                        "`include` set exactly to [`fleets/stopship.toml`, `crates/cli/src/lib.rs`, `crates/workflow/src/role_resolve.rs`, `crates/tui/src/tools/workflow.rs`, `crates/lane/src/runtime.rs`]"
700                    ) && leaf.prompt.contains("Matches outside that exact include list do not count"),
701                    "the one grep must constrain the actual tool input, not only File scope metadata"
702                );
703                assert!(
704                    leaf.prompt.contains("if you can populate all seven")
705                        && leaf
706                            .prompt
707                            .contains("never return BLOCK after citing all seven")
708                        && leaf
709                            .prompt
710                            .contains("identify each missing owner as MISSING"),
711                    "the scout verdict must follow its own complete evidence artifact"
712                );
713            } else {
714                assert!(
715                    leaf.prompt.contains("Tools are intentionally unavailable")
716                        && leaf.prompt.contains("promoted handoff")
717                        && leaf.prompt.contains("all seven owners")
718                        && leaf.prompt.contains("one concise row per owner"),
719                    "{expected_role} must consume promoted evidence without reopening discovery"
720                );
721            }
722            assert_eq!(
723                leaf.file_scope
724                    .iter()
725                    .map(String::as_str)
726                    .collect::<Vec<_>>(),
727                vec![
728                    "fleets/stopship.toml",
729                    "crates/cli/src/lib.rs",
730                    "crates/workflow/src/role_resolve.rs",
731                    "crates/tui/src/tools/workflow.rs",
732                    "crates/lane/src/runtime.rs",
733                ],
734                "every acceptance role must carry the same promoted evidence boundary"
735            );
736            assert_eq!(leaf.budget.max_steps, Some(max_steps), "{expected_role}");
737            let response_budget = match max_steps {
738                6 => "at most six model responses",
739                4 => "at most four model responses",
740                3 => "at most three model responses",
741                _ => unreachable!("unexpected stopship model-response budget"),
742            };
743            assert!(
744                leaf.prompt.contains(response_budget) && leaf.prompt.contains("with no tool calls"),
745                "{expected_role} must reserve a response for its explicit verdict"
746            );
747            assert_eq!(
748                leaf.budget.timeout_secs,
749                Some(timeout_secs),
750                "{expected_role}"
751            );
752            assert_eq!(leaf.budget.max_tokens, Some(max_tokens), "{expected_role}");
753            assert!(
754                max_tokens < u64::from(max_steps) * 24_000,
755                "{expected_role} verdict reserve must not raise its token ceiling"
756            );
757            aggregate_token_cap = aggregate_token_cap.saturating_add(max_tokens);
758            assert!(
759                leaf.profile.is_none(),
760                "Fleet must resolve the declared role"
761            );
762        }
763        assert_eq!(
764            aggregate_token_cap, 360_000,
765            "the fixture must stay globally bounded when no shared override is supplied"
766        );
767
768        let expected_gates = [
769            ("scout", Some("implementer"), "source_evidence"),
770            ("implementer", Some("reviewer"), "verification_plan"),
771            ("reviewer", Some("verifier"), "review_report"),
772            ("verifier", Some("release_lead"), "verification_report"),
773            ("release_lead", None, "final_receipt"),
774        ];
775        assert_eq!(workflow.gates.len(), expected_gates.len());
776        for (gate, (role, blocked_role, artifact_kind)) in workflow.gates.iter().zip(expected_gates)
777        {
778            assert_eq!(gate.role, role);
779            assert_eq!(gate.on, GateOn::RoleComplete);
780            assert_eq!(gate.on_fail, GateOnFail::Block);
781            assert_eq!(gate.blocks_role.as_deref(), blocked_role);
782            assert_eq!(gate.max_retries, 0);
783            assert_eq!(gate.artifact_kind.as_deref(), Some(artifact_kind));
784            assert!(
785                gate.require_explicit_verdict,
786                "{role} gate must fail closed when its verdict is missing or malformed"
787            );
788        }
789
790        let mut board = LaneGateBoard::new("lane-fixture-contract");
791        board.install_gates(&workflow.gates);
792        assert_eq!(
793            board
794                .evaluate(&workflow.gates[0], GateOutcome::Pass)
795                .expect("successful role promotes its gate"),
796            GateState::Passed
797        );
798        let failure = board
799            .evaluate(
800                &workflow.gates[3],
801                GateOutcome::Fail {
802                    reason: "verifier receipt missing".to_string(),
803                },
804            )
805            .expect("failed verifier updates its gate");
806        assert!(matches!(failure, GateState::Blocked { .. }));
807        assert!(
808            board
809                .role_is_blocked(&workflow.gates, "release_lead")
810                .is_some()
811        );
812    }
813
814    #[test]
815    fn javascript_workflow_rejects_invalid_agent_profiles() {
816        for bad in [r#""""#, r#""has space""#, r#""quote\"y""#, r#""a=b""#] {
817            let source = format!(
818                r#"
819workflow({{
820  "goal": "bad profile",
821  "nodes": [
822    {{ "agent": {{ "id": "scan", "prompt": "scan safely", "profile": {bad} }} }}
823  ]
824}});
825"#
826            );
827
828            let err = compile_javascript_workflow("bad-profile.workflow.js", &source)
829                .expect_err("invalid profile should be rejected");
830
831            assert!(
832                matches!(err, JavascriptWorkflowError::InvalidNode(_)),
833                "profile {bad} should fail as an invalid node, got {err:?}"
834            );
835            assert!(err.to_string().contains("profile"));
836        }
837    }
838
839    #[test]
840    fn javascript_workflow_rejects_runtime_effects() {
841        let source = r#"
842import fs from "fs";
843workflow({ "goal": "bad", "nodes": [] });
844"#;
845
846        let err = compile_javascript_workflow("bad.workflow.js", source)
847            .expect_err("imports must be rejected");
848
849        assert!(matches!(
850            err,
851            JavascriptWorkflowError::UnsupportedConstruct {
852                construct: "import"
853            }
854        ));
855    }
856
857    #[test]
858    fn javascript_workflow_rejects_unknown_result_reference() {
859        let source = r#"
860workflow({
861  "goal": "bad dependency",
862  "nodes": [
863    {
864      "agent": {
865        "id": "scan",
866        "prompt": "scan safely",
867        "depends_on_results": ["missing"]
868      }
869    }
870  ]
871});
872"#;
873
874        let err = compile_javascript_workflow("bad-reference.workflow.js", source)
875            .expect_err("validation must reject unknown result references");
876
877        assert!(matches!(err, JavascriptWorkflowError::InvalidNode(_)));
878        assert!(err.to_string().contains("missing"));
879    }
880
881    #[test]
882    fn javascript_example_compiles_and_replays_with_mock_trace() {
883        let source = include_str!("../../../workflows/issue_audit.workflow.js");
884        let workflow =
885            compile_javascript_workflow("issue_audit.workflow.js", source).expect("compile");
886        let trace = crate::WorkflowReplayTrace {
887            trace_id: "empty".to_string(),
888            leaf_records: Vec::new(),
889            control_records: Vec::new(),
890        };
891
892        let replayed = WorkflowReplayExecutor::new(trace)
893            .run(&workflow)
894            .expect("replay executor should accept validated JS IR");
895
896        assert_eq!(replayed.status, crate::WorkflowRunStatus::ReplayDiverged);
897    }
898}