返回 CodeWhale
js_authoring.rs
根目录 / crates / workflow / src / js_authoring.rs
1 use serde::Deserialize;
2 use thiserror::Error;
3
4 use crate::{
5 BranchSpec, BudgetSpec, CondSpec, ExpandSpec, GateSpec, LeafSpec, LoopUntilSpec, ModelPolicy,
6 PermissionSpec, PromotionPolicy, ReduceSpec, SequenceSpec, TeacherReviewSpec, WorkflowNode,
7 WorkflowSpec, validate_workflow_nodes,
8 };
9
10 pub type JavascriptWorkflowResult<T> = std::result::Result<T, JavascriptWorkflowError>;
11
12 #[derive(Debug, Error)]
13 pub 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
26 pub fn compile_javascript_workflow(
27 identifier: &str,
28 source: &str,
29 ) -> JavascriptWorkflowResult<WorkflowSpec> {
30 compile_js_like_workflow(identifier, source)
31 }
32
33 pub fn compile_typescript_workflow(
34 identifier: &str,
35 source: &str,
36 ) -> JavascriptWorkflowResult<WorkflowSpec> {
37 compile_js_like_workflow(identifier, source)
38 }
39
40 fn 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.
64 fn 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
92 fn 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
101 fn 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
130 fn 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)]
194 struct 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
214 impl 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)]
236 enum 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
248 impl 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)]
266 struct JsAgentNode {
267 agent: LeafSpec,
268 }
269
270 #[derive(Debug, Deserialize)]
271 #[serde(deny_unknown_fields)]
272 struct JsBranchNode {
273 branch: JsBranchSpec,
274 }
275
276 #[derive(Debug, Deserialize)]
277 #[serde(deny_unknown_fields)]
278 struct 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
294 impl 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)]
314 struct JsSequenceNode {
315 sequence: JsSequenceSpec,
316 }
317
318 #[derive(Debug, Deserialize)]
319 #[serde(deny_unknown_fields)]
320 struct JsSequenceSpec {
321 id: String,
322 #[serde(default)]
323 children: Vec<JsWorkflowNode>,
324 }
325
326 impl 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)]
341 struct JsReduceNode {
342 reduce: ReduceSpec,
343 }
344
345 #[derive(Debug, Deserialize)]
346 #[serde(deny_unknown_fields)]
347 struct JsTeacherReviewNode {
348 teacher_review: TeacherReviewSpec,
349 }
350
351 #[derive(Debug, Deserialize)]
352 #[serde(deny_unknown_fields)]
353 struct JsLoopUntilNode {
354 loop_until: JsLoopUntilSpec,
355 }
356
357 #[derive(Debug, Deserialize)]
358 #[serde(deny_unknown_fields)]
359 struct JsLoopUntilSpec {
360 id: String,
361 condition: String,
362 #[serde(default)]
363 max_iterations: Option<u32>,
364 #[serde(default)]
365 children: Vec<JsWorkflowNode>,
366 }
367
368 impl 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)]
385 struct JsCondNode {
386 cond: JsCondSpec,
387 }
388
389 #[derive(Debug, Deserialize)]
390 #[serde(deny_unknown_fields)]
391 struct 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
400 impl 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)]
421 struct JsExpandNode {
422 expand: JsExpandSpec,
423 }
424
425 #[derive(Debug, Deserialize)]
426 #[serde(deny_unknown_fields)]
427 struct 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
436 impl 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
447 fn default_true() -> bool {
448 true
449 }
450
451 #[cfg(test)]
452 mod 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#"
462 export 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#"
521 export 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#"
539 workflow({
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#"
564 workflow({
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#"
594 workflow({
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 `File` call")
677 && leaf.prompt.contains("Do not call `File` more than once")
678 && leaf
679 .prompt
680 .contains("do not use any action except `search_content`"),
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 File search 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#"
819 workflow({{
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#"
842 import fs from "fs";
843 workflow({ "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#"
860 workflow({
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 }
899
899 lines RUST