返回 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 };
457
458 #[test]
459 fn javascript_workflow_compiles_branch_reduce_to_ir() {
460 let source = r#"
461 export default workflow({
462 "id": "js-audit",
463 "goal": "Audit a change with parallel agents",
464 "nodes": [
465 {
466 "branch": {
467 "id": "parallel-audit",
468 "children": [
469 {
470 "agent": {
471 "id": "docs-audit",
472 "prompt": "Inspect docs for missing updates",
473 "agent_type": "review",
474 "file_scope": ["docs"]
475 }
476 },
477 {
478 "agent": {
479 "id": "tests-audit",
480 "prompt": "Inspect targeted tests",
481 "agent_type": "verifier",
482 "budget": { "max_steps": 4 }
483 }
484 }
485 ]
486 }
487 },
488 {
489 "reduce": {
490 "id": "synthesize",
491 "inputs": ["docs-audit", "tests-audit"],
492 "prompt": "Merge the branch findings"
493 }
494 }
495 ]
496 });
497 "#;
498
499 let workflow =
500 compile_javascript_workflow("audit.workflow.js", source).expect("compile JS workflow");
501
502 assert_eq!(workflow.id.as_deref(), Some("js-audit"));
503 assert_eq!(workflow.nodes.len(), 2);
504 let WorkflowNode::BranchSet(branch) = &workflow.nodes[0] else {
505 panic!("first node should be a branch");
506 };
507 assert!(branch.parallel);
508 assert_eq!(branch.children.len(), 2);
509 let WorkflowNode::Leaf(leaf) = &branch.children[1] else {
510 panic!("second branch child should be a leaf");
511 };
512 assert_eq!(leaf.agent_type, AgentType::Verifier);
513 assert_eq!(leaf.budget.max_steps, Some(4));
514 assert!(matches!(workflow.nodes[1], WorkflowNode::Reduce(_)));
515 }
516
517 #[test]
518 fn typescript_workflow_allows_satisfies_suffix_without_executing_js() {
519 let source = r#"
520 export default workflow({
521 "goal": "TS authored workflow",
522 "nodes": [
523 { "agent": { "id": "scan", "prompt": "scan safely" } }
524 ]
525 } satisfies WorkflowSpec);
526 "#;
527
528 let workflow =
529 compile_typescript_workflow("scan.workflow.ts", source).expect("compile TS workflow");
530
531 assert_eq!(workflow.goal, "TS authored workflow");
532 assert_eq!(workflow.nodes.len(), 1);
533 }
534
535 #[test]
536 fn javascript_workflow_accepts_and_normalizes_agent_profile() {
537 let source = r#"
538 workflow({
539 "goal": "profile routing",
540 "nodes": [
541 { "agent": { "id": "review", "prompt": "review the diff", "profile": " Reviewer " } },
542 { "agent": { "id": "scan", "prompt": "scan safely" } }
543 ]
544 });
545 "#;
546
547 let workflow = compile_javascript_workflow("profile.workflow.js", source)
548 .expect("profile-carrying workflow should compile");
549
550 let WorkflowNode::Leaf(review) = &workflow.nodes[0] else {
551 panic!("first node should be a leaf");
552 };
553 assert_eq!(review.profile.as_deref(), Some("reviewer"));
554 let WorkflowNode::Leaf(scan) = &workflow.nodes[1] else {
555 panic!("second node should be a leaf");
556 };
557 assert_eq!(scan.profile, None);
558 }
559
560 #[test]
561 fn javascript_workflow_accepts_and_normalizes_agent_role() {
562 let source = r#"
563 workflow({
564 "goal": "role routing",
565 "nodes": [
566 { "agent": { "id": "scout-issue", "prompt": "Investigate #4090. Read-only.", "role": " Scout " } },
567 { "agent": { "id": "fix-it", "prompt": "Apply minimal fix.", "role": "implementer" } }
568 ]
569 });
570 "#;
571
572 let workflow = compile_javascript_workflow("role.workflow.js", source)
573 .expect("role-carrying workflow should compile");
574
575 let WorkflowNode::Leaf(scout) = &workflow.nodes[0] else {
576 panic!("first node should be a leaf");
577 };
578 assert_eq!(scout.role.as_deref(), Some("scout"));
579 assert_eq!(scout.profile, None);
580 // Provider/model are not required identity fields on role steps.
581 assert_eq!(scout.model_policy.provider, None);
582 assert_eq!(scout.model_policy.model, None);
583
584 let WorkflowNode::Leaf(fix) = &workflow.nodes[1] else {
585 panic!("second node should be a leaf");
586 };
587 assert_eq!(fix.role.as_deref(), Some("implementer"));
588 }
589
590 #[test]
591 fn javascript_workflow_accepts_gate_specs() {
592 let source = r#"
593 workflow({
594 "goal": "role gates",
595 "gates": [
596 {
597 "id": "scout-findings",
598 "role": " Scout ",
599 "on": "role_complete",
600 "gate": "approve",
601 "on_fail": "block",
602 "blocks_role": " Implementer ",
603 "artifact_kind": "findings"
604 }
605 ],
606 "nodes": [
607 { "agent": { "id": "scout", "prompt": "Find risk.", "role": "scout" } },
608 { "agent": { "id": "fix", "prompt": "Use findings.", "role": "implementer" } }
609 ]
610 });
611 "#;
612
613 let workflow =
614 compile_javascript_workflow("gates.workflow.js", source).expect("compile gates");
615
616 assert_eq!(workflow.gates.len(), 1);
617 let gate = &workflow.gates[0];
618 assert_eq!(gate.id, "scout-findings");
619 assert_eq!(gate.role, "scout");
620 assert_eq!(gate.on, GateOn::RoleComplete);
621 assert_eq!(gate.gate, GateKind::Approve);
622 assert_eq!(gate.on_fail, GateOnFail::Block);
623 assert_eq!(gate.blocks_role.as_deref(), Some("implementer"));
624 assert_eq!(gate.artifact_kind.as_deref(), Some("findings"));
625 }
626
627 #[test]
628 fn stopship_acceptance_fixture_is_read_only_and_gate_complete() {
629 let source = include_str!("../../../workflows/stopship.workflow.js");
630 let workflow = compile_javascript_workflow("stopship.workflow.js", source)
631 .expect("compile stopship acceptance fixture");
632
633 assert_eq!(workflow.id.as_deref(), Some("stopship-release-acceptance"));
634 let WorkflowNode::Sequence(sequence) = &workflow.nodes[0] else {
635 panic!("acceptance fixture should begin with one ordered role chain");
636 };
637 let expected_children = [
638 ("explore", 6, 480, 96_000),
639 ("implement", 4, 420, 72_000),
640 ("reviewer", 4, 420, 72_000),
641 ("test", 4, 420, 72_000),
642 ("release_lead", 3, 300, 48_000),
643 ];
644 let mut aggregate_token_cap = 0_u64;
645 assert_eq!(sequence.children.len(), expected_children.len());
646 for (node, (expected_role, max_steps, timeout_secs, max_tokens)) in
647 sequence.children.iter().zip(expected_children)
648 {
649 let WorkflowNode::Leaf(leaf) = node else {
650 panic!("acceptance role chain must contain only agent leaves");
651 };
652 assert_eq!(leaf.role.as_deref(), Some(expected_role));
653 assert_eq!(leaf.mode, TaskMode::ReadOnly);
654 assert!(!leaf.permissions.allow_write);
655 // No explicit `allowed_tools` anywhere: the read-only lowering
656 // alone scopes the source-gathering explore role, and its
657 // catalog-visible surface (tool_search + deferred grep_files) is
658 // pinned from the TUI side (scout_surface_keeps_tool_search_
659 // grep_files_activation_path).
660 assert!(leaf.permissions.allowed_tools.is_empty());
661 assert_eq!(
662 leaf.permissions.deny_all_tools,
663 expected_role != "explore",
664 "only the source-gathering explore role should receive tools"
665 );
666 assert!(
667 leaf.prompt.contains(
668 "first non-empty line of your response must be exactly APPROVE or exactly BLOCK"
669 ),
670 "{expected_role} must declare the host-readable verdict contract"
671 );
672 assert!(
673 leaf.prompt
674 .contains("Do not put any words before that verdict")
675 && leaf.prompt.contains("Here is the verdict"),
676 "{expected_role} must reject verdict preambles that the host cannot parse"
677 );
678 if expected_role == "explore" {
679 assert!(
680 leaf.prompt.contains("exactly one `tool_search` call")
681 && leaf.prompt.contains("exactly one `grep_files` call")
682 && leaf.prompt.contains(
683 "the content-search tool is deferred, so this activation is required before it can be called"
684 ),
685 "the scout must activate the deferred content-search tool before searching with it"
686 );
687 assert!(
688 leaf.prompt.contains("Make no other tool calls")
689 && leaf.prompt.contains("nothing else"),
690 "the scout discovery must stay one bounded activation-plus-search round"
691 );
692 assert_eq!(
693 leaf.file_scope
694 .iter()
695 .map(String::as_str)
696 .collect::<Vec<_>>(),
697 vec![
698 "fleets/stopship.toml",
699 "crates/cli/src/lib.rs",
700 "crates/workflow/src/role_resolve.rs",
701 "crates/tui/src/tools/workflow/mod.rs",
702 "crates/lane/src/runtime.rs",
703 ],
704 "the scout grep must not include its own authored prompt"
705 );
706 let repository_root =
707 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
708 for path in &leaf.file_scope {
709 assert!(
710 repository_root.join(path).is_file(),
711 "scout evidence path must exist: {path}"
712 );
713 }
714 assert!(
715 leaf.prompt.contains(
716 "`include` set exactly to [`fleets/stopship.toml`, `crates/cli/src/lib.rs`, `crates/workflow/src/role_resolve.rs`, `crates/tui/src/tools/workflow/mod.rs`, `crates/lane/src/runtime.rs`]"
717 ) && leaf.prompt.contains("Matches outside that exact include list do not count"),
718 "the grep_files search must constrain the actual tool input, not only file scope metadata"
719 );
720 assert!(
721 leaf.prompt.contains("if you can populate all seven")
722 && leaf
723 .prompt
724 .contains("never return BLOCK after citing all seven")
725 && leaf
726 .prompt
727 .contains("identify each missing owner as MISSING"),
728 "the scout verdict must follow its own complete evidence artifact"
729 );
730 } else {
731 assert!(
732 leaf.prompt.contains("Tools are intentionally unavailable")
733 && leaf.prompt.contains("promoted handoff")
734 && leaf.prompt.contains("all seven owners")
735 && leaf.prompt.contains("one concise row per owner"),
736 "{expected_role} must consume promoted evidence without reopening discovery"
737 );
738 }
739 assert_eq!(
740 leaf.file_scope
741 .iter()
742 .map(String::as_str)
743 .collect::<Vec<_>>(),
744 vec![
745 "fleets/stopship.toml",
746 "crates/cli/src/lib.rs",
747 "crates/workflow/src/role_resolve.rs",
748 "crates/tui/src/tools/workflow/mod.rs",
749 "crates/lane/src/runtime.rs",
750 ],
751 "every acceptance role must carry the same promoted evidence boundary"
752 );
753 assert_eq!(leaf.budget.max_steps, Some(max_steps), "{expected_role}");
754 let response_budget = match max_steps {
755 6 => "at most six model responses",
756 4 => "at most four model responses",
757 3 => "at most three model responses",
758 _ => unreachable!("unexpected stopship model-response budget"),
759 };
760 assert!(
761 leaf.prompt.contains(response_budget) && leaf.prompt.contains("with no tool calls"),
762 "{expected_role} must reserve a response for its explicit verdict"
763 );
764 assert_eq!(
765 leaf.budget.timeout_secs,
766 Some(timeout_secs),
767 "{expected_role}"
768 );
769 assert_eq!(leaf.budget.max_tokens, Some(max_tokens), "{expected_role}");
770 assert!(
771 max_tokens < u64::from(max_steps) * 24_000,
772 "{expected_role} verdict reserve must not raise its token ceiling"
773 );
774 aggregate_token_cap = aggregate_token_cap.saturating_add(max_tokens);
775 assert!(
776 leaf.profile.is_none(),
777 "Fleet must resolve the declared role"
778 );
779 }
780 assert_eq!(
781 aggregate_token_cap, 360_000,
782 "the fixture must stay globally bounded when no shared override is supplied"
783 );
784
785 let expected_gates = [
786 ("explore", Some("implement"), "source_evidence"),
787 ("implement", Some("reviewer"), "verification_plan"),
788 ("reviewer", Some("test"), "review_report"),
789 ("test", Some("release_lead"), "verification_report"),
790 ("release_lead", None, "final_receipt"),
791 ];
792 assert_eq!(workflow.gates.len(), expected_gates.len());
793 for (gate, (role, blocked_role, artifact_kind)) in workflow.gates.iter().zip(expected_gates)
794 {
795 assert_eq!(gate.role, role);
796 assert_eq!(gate.on, GateOn::RoleComplete);
797 assert_eq!(gate.on_fail, GateOnFail::Block);
798 assert_eq!(gate.blocks_role.as_deref(), blocked_role);
799 assert_eq!(gate.max_retries, 0);
800 assert_eq!(gate.artifact_kind.as_deref(), Some(artifact_kind));
801 assert!(
802 gate.require_explicit_verdict,
803 "{role} gate must fail closed when its verdict is missing or malformed"
804 );
805 }
806
807 let mut board = LaneGateBoard::new("lane-fixture-contract");
808 board.install_gates(&workflow.gates);
809 assert_eq!(
810 board
811 .evaluate(&workflow.gates[0], GateOutcome::Pass)
812 .expect("successful role promotes its gate"),
813 GateState::Passed
814 );
815 let failure = board
816 .evaluate(
817 &workflow.gates[3],
818 GateOutcome::Fail {
819 reason: "verifier receipt missing".to_string(),
820 },
821 )
822 .expect("failed verifier updates its gate");
823 assert!(matches!(failure, GateState::Blocked { .. }));
824 assert!(
825 board
826 .role_is_blocked(&workflow.gates, "release_lead")
827 .is_some()
828 );
829 }
830
831 #[test]
832 fn javascript_workflow_rejects_invalid_agent_profiles() {
833 for bad in [r#""""#, r#""has space""#, r#""quote\"y""#, r#""a=b""#] {
834 let source = format!(
835 r#"
836 workflow({{
837 "goal": "bad profile",
838 "nodes": [
839 {{ "agent": {{ "id": "scan", "prompt": "scan safely", "profile": {bad} }} }}
840 ]
841 }});
842 "#
843 );
844
845 let err = compile_javascript_workflow("bad-profile.workflow.js", &source)
846 .expect_err("invalid profile should be rejected");
847
848 assert!(
849 matches!(err, JavascriptWorkflowError::InvalidNode(_)),
850 "profile {bad} should fail as an invalid node, got {err:?}"
851 );
852 assert!(err.to_string().contains("profile"));
853 }
854 }
855
856 #[test]
857 fn javascript_workflow_rejects_runtime_effects() {
858 let source = r#"
859 import fs from "fs";
860 workflow({ "goal": "bad", "nodes": [] });
861 "#;
862
863 let err = compile_javascript_workflow("bad.workflow.js", source)
864 .expect_err("imports must be rejected");
865
866 assert!(matches!(
867 err,
868 JavascriptWorkflowError::UnsupportedConstruct {
869 construct: "import"
870 }
871 ));
872 }
873
874 #[test]
875 fn javascript_workflow_rejects_unknown_result_reference() {
876 let source = r#"
877 workflow({
878 "goal": "bad dependency",
879 "nodes": [
880 {
881 "agent": {
882 "id": "scan",
883 "prompt": "scan safely",
884 "depends_on_results": ["missing"]
885 }
886 }
887 ]
888 });
889 "#;
890
891 let err = compile_javascript_workflow("bad-reference.workflow.js", source)
892 .expect_err("validation must reject unknown result references");
893
894 assert!(matches!(err, JavascriptWorkflowError::InvalidNode(_)));
895 assert!(err.to_string().contains("missing"));
896 }
897
898 #[test]
899 fn javascript_example_compiles_to_validated_ir() {
900 let source = include_str!("../../../workflows/issue_audit.workflow.js");
901 let workflow =
902 compile_javascript_workflow("issue_audit.workflow.js", source).expect("compile");
903
904 workflow
905 .validate_for_fleet()
906 .expect("example lowers to IR that passes Fleet validation");
907 assert_eq!(workflow.id.as_deref(), Some("issue-audit-js"));
908 assert_eq!(workflow.nodes.len(), 2, "{:#?}", workflow.nodes);
909 let WorkflowNode::BranchSet(branch) = &workflow.nodes[0] else {
910 panic!("first node should be the parallel audit branch");
911 };
912 let leaf_ids: Vec<&str> = branch
913 .children
914 .iter()
915 .map(|child| match child {
916 WorkflowNode::Leaf(leaf) => leaf.id.as_str(),
917 other => panic!("audit branch child should be an agent leaf: {other:?}"),
918 })
919 .collect();
920 assert_eq!(leaf_ids, ["code-audit", "test-audit", "docs-audit"]);
921 let WorkflowNode::Reduce(reduce) = &workflow.nodes[1] else {
922 panic!("second node should reduce the audit findings");
923 };
924 assert_eq!(reduce.id, "synthesize-release-risk");
925 assert_eq!(reduce.inputs, leaf_ids);
926 }
927 }
928
928 lines RUST