1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "camelCase")]
9pub struct ParamDef {
10 #[serde(rename = "type")]
11 pub param_type: String,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub format: Option<String>,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub required: Option<bool>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub default: Option<Value>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub description: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(rename_all = "camelCase")]
24pub struct RetryPolicy {
25 pub max_attempts: u64,
26 pub backoff: String,
27 pub base_ms: u64,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub factor: Option<f64>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub jitter: Option<bool>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct HumanGate {
37 pub stage: String,
38 pub prompt: Value,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub approvers: Option<Vec<String>>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub deadline_ms: Option<u64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub on_timeout: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "camelCase")]
49pub struct LoopTerminate {
50 pub node: String,
51 pub via: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55#[serde(rename_all = "camelCase")]
56pub struct LoopOutputProjection {
57 pub from: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61#[serde(rename_all = "camelCase")]
62pub struct NodeBase {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub description: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub depends: Option<Vec<String>>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub human_gate: Option<HumanGate>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub retry_policy: Option<RetryPolicy>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub timeout_ms: Option<u64>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub max_output_bytes: Option<u64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub output_schema: Option<Value>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub unsafe_allow_ungated: Option<bool>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82#[serde(rename_all = "camelCase")]
83pub struct SubagentNode {
84 #[serde(flatten)]
85 pub base: NodeBase,
86 pub bot: String,
87 pub prompt: Value,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub working_dir: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub model_overrides: Option<Value>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub tool_policy: Option<Value>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97#[serde(rename_all = "camelCase")]
98pub struct HostExecutorNode {
99 #[serde(flatten)]
100 pub base: NodeBase,
101 pub executor: String,
102 pub input: Value,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
106#[serde(rename_all = "camelCase")]
107pub struct LoopNode {
108 #[serde(flatten)]
109 pub base: NodeBase,
110 pub max_iterations: u64,
111 pub body: Vec<String>,
112 pub terminate: LoopTerminate,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub output: Option<LoopOutputProjection>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(rename_all = "camelCase")]
119pub struct DecisionNode {
120 #[serde(flatten)]
121 pub base: NodeBase,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125#[serde(tag = "type", rename_all = "camelCase")]
126pub enum WorkflowNode {
127 Subagent(SubagentNode),
128 HostExecutor(HostExecutorNode),
129 Loop(LoopNode),
130 Decision(DecisionNode),
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134#[serde(rename_all = "camelCase")]
135pub struct WorkflowDefaults {
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub retry_policy: Option<RetryPolicy>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub timeout_ms: Option<u64>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub max_output_bytes: Option<u64>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub max_concurrency: Option<u64>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147#[serde(rename_all = "camelCase")]
148pub struct WorkflowDefinition {
149 pub workflow_id: String,
150 pub version: u64,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub params: Option<BTreeMap<String, ParamDef>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub defaults: Option<WorkflowDefaults>,
155 pub nodes: BTreeMap<String, WorkflowNode>,
156}
157
158pub fn parse_workflow_definition(raw: &str) -> Result<WorkflowDefinition> {
159 let def: WorkflowDefinition =
160 serde_json::from_str(raw).context("failed to parse workflow json")?;
161 validate_workflow_definition(&def)?;
162 Ok(def)
163}
164
165const SIDE_EFFECT_EXECUTORS: &[&str] = &["feishu-send", "feishu-reply", "beam-schedule"];
167
168fn is_side_effect_executor(executor: &str) -> bool {
169 SIDE_EFFECT_EXECUTORS.contains(&executor)
170}
171
172pub fn validate_workflow_definition(def: &WorkflowDefinition) -> Result<()> {
173 if def.workflow_id.trim().is_empty() {
174 anyhow::bail!("workflowId 缺失");
175 }
176 if def.version == 0 {
177 anyhow::bail!("version must be positive");
178 }
179 if def.nodes.is_empty() {
180 anyhow::bail!("Workflow must declare at least one node");
181 }
182 for node_id in def.nodes.keys() {
183 if node_id.is_empty() {
185 anyhow::bail!(
186 "nodeId '' rejected: must match ^[A-Za-z0-9_.-]+$"
187 );
188 }
189 if !node_id
190 .chars()
191 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
192 {
193 anyhow::bail!(
194 "nodeId '{}' rejected: must match ^[A-Za-z0-9_.-]+$",
195 node_id
196 );
197 }
198 if node_id == "." || node_id == ".." || node_id.contains("..") {
200 anyhow::bail!(
201 "nodeId '{}' rejected: path-traversal style ids are not allowed",
202 node_id
203 );
204 }
205 }
206 for (node_id, node) in &def.nodes {
207 match node {
208 WorkflowNode::HostExecutor(host) => {
209 if is_side_effect_executor(&host.executor)
211 && host.base.human_gate.is_none()
212 && !host.base.unsafe_allow_ungated.unwrap_or(false)
213 {
214 anyhow::bail!(
215 "nodeId '{}': side-effect executor '{}' must have a humanGate or set unsafeAllowUngated: true",
216 node_id,
217 host.executor
218 );
219 }
220 }
221 _ => {}
222 }
223 }
224 for (node_id, node) in &def.nodes {
225 for dep in node_depends(node) {
226 if !def.nodes.contains_key(dep) {
227 anyhow::bail!("Node '{}' depends on unknown node '{}'", node_id, dep);
228 }
229 if dep == node_id {
230 anyhow::bail!("Node '{}' depends on itself", node_id);
231 }
232 }
233 }
234 detect_cycles(def)?;
235
236 validate_loop_definitions(def)?;
238
239 let body_owner = build_body_owner_map(def);
242 let has_root = def.nodes.iter().any(|(node_id, node)| {
243 if body_owner.contains_key(node_id) {
244 return false;
245 }
246 if matches!(node, WorkflowNode::Decision(_)) {
247 return false;
248 }
249 node_depends(node).is_empty()
250 });
251 if !has_root {
252 anyhow::bail!(
253 "Workflow has no scheduler-visible root node (every non-loop-body, non-decision node has dependencies)"
254 );
255 }
256 Ok(())
257}
258
259fn node_depends(node: &WorkflowNode) -> &[String] {
260 match node {
261 WorkflowNode::Subagent(n) => n.base.depends.as_deref().unwrap_or(&[]),
262 WorkflowNode::HostExecutor(n) => n.base.depends.as_deref().unwrap_or(&[]),
263 WorkflowNode::Loop(n) => n.base.depends.as_deref().unwrap_or(&[]),
264 WorkflowNode::Decision(n) => n.base.depends.as_deref().unwrap_or(&[]),
265 }
266}
267
268fn detect_cycles(def: &WorkflowDefinition) -> Result<()> {
269 #[derive(Clone, Copy, PartialEq, Eq)]
270 enum Mark {
271 Temp,
272 Perm,
273 }
274 fn visit(
275 node_id: &str,
276 def: &WorkflowDefinition,
277 marks: &mut BTreeMap<String, Mark>,
278 stack: &mut BTreeSet<String>,
279 ) -> Result<()> {
280 if matches!(marks.get(node_id), Some(Mark::Perm)) {
281 return Ok(());
282 }
283 if matches!(marks.get(node_id), Some(Mark::Temp)) {
284 anyhow::bail!("Workflow graph contains a cycle at '{}'", node_id);
285 }
286 marks.insert(node_id.to_string(), Mark::Temp);
287 stack.insert(node_id.to_string());
288 for dep in node_depends(def.nodes.get(node_id).expect("node must exist")) {
289 visit(dep, def, marks, stack)?;
290 }
291 stack.remove(node_id);
292 marks.insert(node_id.to_string(), Mark::Perm);
293 Ok(())
294 }
295
296 let mut marks = BTreeMap::new();
297 let mut stack = BTreeSet::new();
298 for node_id in def.nodes.keys() {
299 visit(node_id, def, &mut marks, &mut stack)?;
300 }
301 Ok(())
302}
303
304fn build_body_owner_map(def: &WorkflowDefinition) -> HashMap<String, String> {
306 let mut owner = HashMap::new();
307 for (loop_id, node) in &def.nodes {
308 if let WorkflowNode::Loop(loop_node) = node {
309 for body_id in &loop_node.body {
310 owner.insert(body_id.clone(), loop_id.clone());
311 }
312 }
313 }
314 owner
315}
316
317fn validate_loop_definitions(def: &WorkflowDefinition) -> Result<()> {
328 let body_owner = build_body_owner_map(def);
329
330 let mut decision_loop_owner: HashMap<String, String> = HashMap::new();
332
333 for (loop_id, node) in &def.nodes {
334 let loop_node = match node {
335 WorkflowNode::Loop(ln) => ln,
336 _ => continue,
337 };
338
339 for body_id in &loop_node.body {
341 if !def.nodes.contains_key(body_id) {
342 anyhow::bail!(
343 "loop '{}' body node '{}' not found in workflow nodes",
344 loop_id,
345 body_id
346 );
347 }
348 }
349
350 for body_id in &loop_node.body {
352 if matches!(def.nodes.get(body_id), Some(WorkflowNode::Loop(_))) {
353 anyhow::bail!(
354 "loop '{}' body node '{}' cannot be a Loop node (nested loops are not supported)",
355 loop_id,
356 body_id
357 );
358 }
359 }
360
361 let term_node_id = &loop_node.terminate.node;
363 if !def.nodes.contains_key(term_node_id) {
364 anyhow::bail!(
365 "loop '{}' terminate.node '{}' not found in workflow nodes",
366 loop_id,
367 term_node_id
368 );
369 }
370
371 if !loop_node.body.contains(term_node_id) {
373 anyhow::bail!(
374 "loop '{}' terminate.node '{}' must be in the loop body",
375 loop_id,
376 term_node_id
377 );
378 }
379
380 match def.nodes.get(term_node_id) {
383 Some(WorkflowNode::Decision(_)) => {
384 if let Some(existing_owner) =
386 decision_loop_owner.insert(term_node_id.clone(), loop_id.clone())
387 {
388 anyhow::bail!(
389 "Decision node '{}' is used as terminate.node by multiple loops: '{}' and '{}'",
390 term_node_id,
391 existing_owner,
392 loop_id
393 );
394 }
395 }
396 _ => {
397 anyhow::bail!(
398 "loop '{}' terminate.node '{}' must be a Decision node, got {:?}",
399 loop_id,
400 term_node_id,
401 std::mem::discriminant(def.nodes.get(term_node_id).unwrap())
402 );
403 }
404 }
405
406 for body_id in &loop_node.body {
408 if body_id != term_node_id
409 && matches!(def.nodes.get(body_id), Some(WorkflowNode::Decision(_)))
410 {
411 anyhow::bail!(
412 "loop '{}' has Decision node '{}' in body that is not the terminate.node; \
413 each loop body can have at most one Decision node (which must be the terminate.node)",
414 loop_id,
415 body_id
416 );
417 }
418 }
419
420 let loop_depends: BTreeSet<&str> = loop_node
422 .base
423 .depends
424 .as_ref()
425 .map(|v| v.iter().map(String::as_str).collect())
426 .unwrap_or_default();
427 for body_id in &loop_node.body {
428 if let Some(body_node) = def.nodes.get(body_id) {
429 for dep in node_depends(body_node) {
430 if !loop_node.body.contains(dep) {
431 if !loop_depends.contains(dep.as_str()) {
433 anyhow::bail!(
434 "loop '{}' body node '{}' depends on external node '{}'; \
435 all external dependencies of body nodes must be declared in the loop's depends",
436 loop_id,
437 body_id,
438 dep
439 );
440 }
441 }
442 }
443 }
444 }
445 }
446
447 for (node_id, node) in &def.nodes {
449 if matches!(node, WorkflowNode::Decision(_)) {
450 if !decision_loop_owner.contains_key(node_id) {
451 anyhow::bail!(
452 "Decision node '{}' is standalone; Decision nodes must be used as a loop's terminate.node and reside in that loop's body",
453 node_id
454 );
455 }
456 }
457 }
458
459 for (node_id, node) in &def.nodes {
463 if body_owner.contains_key(node_id) {
464 continue; }
466 if matches!(node, WorkflowNode::Decision(_)) {
467 continue; }
470 for dep in node_depends(node) {
471 if body_owner.contains_key(dep) {
472 let owner = body_owner.get(dep).unwrap();
473 anyhow::bail!(
474 "node '{}' depends on loop body node '{}'; \
475 nodes must depend on the loop node '{}' instead of its body node",
476 node_id,
477 dep,
478 owner
479 );
480 }
481 }
482 }
483
484 for (loop_id, node) in &def.nodes {
486 let WorkflowNode::Loop(loop_node) = node else {
487 continue;
488 };
489 let Some(output) = &loop_node.output else {
490 continue;
491 };
492 if !loop_node.body.contains(&output.from) {
493 anyhow::bail!(
494 "loop '{}' output.from '{}' must identify a node in the loop body",
495 loop_id,
496 output.from
497 );
498 }
499 if !matches!(
500 def.nodes.get(&output.from),
501 Some(WorkflowNode::Subagent(_) | WorkflowNode::HostExecutor(_))
502 ) {
503 anyhow::bail!(
504 "loop '{}' output.from '{}' must identify an output-producing Subagent or HostExecutor body node",
505 loop_id,
506 output.from
507 );
508 }
509 }
510
511 let sinks = find_non_body_sinks(def, &body_owner);
514 for sink_id in &sinks {
515 if let Some(WorkflowNode::Loop(loop_node)) = def.nodes.get(sink_id) {
516 if loop_node.output.is_none() {
517 anyhow::bail!(
518 "sink loop '{}' must declare output.from (the loop is not depended on by any external node)",
519 sink_id
520 );
521 }
522 }
523 }
524
525 for (loop_id, node) in &def.nodes {
527 let WorkflowNode::Loop(loop_node) = node else {
528 continue;
529 };
530 if loop_node.terminate.via != "humanGate" {
531 anyhow::bail!(
532 "loop '{}' terminate.via must be 'humanGate', got '{}'",
533 loop_id,
534 loop_node.terminate.via
535 );
536 }
537 let decision = match def.nodes.get(&loop_node.terminate.node) {
538 Some(WorkflowNode::Decision(decision)) => decision,
539 _ => continue,
540 };
541 if decision.base.human_gate.is_none() {
542 anyhow::bail!(
543 "loop '{}' terminate.node '{}' must declare humanGate",
544 loop_id,
545 loop_node.terminate.node
546 );
547 }
548 }
549
550 Ok(())
551}
552
553fn find_non_body_sinks(
556 def: &WorkflowDefinition,
557 body_owner: &HashMap<String, String>,
558) -> Vec<String> {
559 let mut referenced: BTreeSet<String> = BTreeSet::new();
560 for (node_id, node) in &def.nodes {
561 if body_owner.contains_key(node_id) {
562 continue;
563 }
564 if matches!(node, WorkflowNode::Decision(_)) {
565 continue;
566 }
567 for dep in node_depends(node) {
568 referenced.insert(dep.clone());
569 }
570 }
571 def.nodes
572 .iter()
573 .filter_map(|(node_id, node)| {
574 if body_owner.contains_key(node_id) {
575 return None;
576 }
577 if matches!(node, WorkflowNode::Decision(_)) {
578 return None;
579 }
580 (!referenced.contains(node_id)).then_some(node_id.clone())
581 })
582 .collect()
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn parse_workflow_definition_accepts_basic_graph() {
591 let def = parse_workflow_definition(
592 r#"{
593 "workflowId":"flow-a",
594 "version":1,
595 "nodes":{
596 "a":{"type":"subagent","bot":"bot-a","prompt":"hi"},
597 "b":{"type":"hostExecutor","executor":"feishu-send","input":1,"depends":["a"],"humanGate":{"stage":"before","prompt":"approve?"}}
598 }
599 }"#,
600 )
601 .expect("definition");
602 assert_eq!(def.workflow_id, "flow-a");
603 assert_eq!(def.nodes.len(), 2);
604 }
605
606 #[test]
609 fn reject_node_id_with_slash() {
610 let err = parse_workflow_definition(
611 r#"{"workflowId":"f","version":1,"nodes":{"node/a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
612 )
613 .unwrap_err();
614 assert!(err.to_string().contains("nodeId"), "got: {err}");
615 }
616
617 #[test]
618 fn reject_node_id_with_space() {
619 let err = parse_workflow_definition(
620 r#"{"workflowId":"f","version":1,"nodes":{"node a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
621 )
622 .unwrap_err();
623 assert!(err.to_string().contains("nodeId"), "got: {err}");
624 }
625
626 #[test]
627 fn reject_node_id_dotdot() {
628 let err = parse_workflow_definition(
629 r#"{"workflowId":"f","version":1,"nodes":{"..":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
630 )
631 .unwrap_err();
632 assert!(err.to_string().contains("nodeId"), "got: {err}");
633 }
634
635 #[test]
636 fn reject_node_id_containing_dotdot() {
637 let err = parse_workflow_definition(
638 r#"{"workflowId":"f","version":1,"nodes":{"a..b":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
639 )
640 .unwrap_err();
641 assert!(err.to_string().contains("nodeId"), "got: {err}");
642 }
643
644 #[test]
645 fn reject_empty_node_id() {
646 let err = parse_workflow_definition(
647 r#"{"workflowId":"f","version":1,"nodes":{"":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
648 )
649 .unwrap_err();
650 assert!(err.to_string().contains("nodeId"), "got: {err}");
651 }
652
653 #[test]
654 fn accept_node_id_with_dash() {
655 let def = parse_workflow_definition(
656 r#"{"workflowId":"f","version":1,"nodes":{"node-a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
657 )
658 .expect("dash ok");
659 assert!(def.nodes.contains_key("node-a"));
660 }
661
662 #[test]
663 fn accept_node_id_with_underscore() {
664 let def = parse_workflow_definition(
665 r#"{"workflowId":"f","version":1,"nodes":{"node_a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
666 )
667 .expect("underscore ok");
668 assert!(def.nodes.contains_key("node_a"));
669 }
670
671 #[test]
672 fn accept_node_id_with_dot() {
673 let def = parse_workflow_definition(
674 r#"{"workflowId":"f","version":1,"nodes":{"node.a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
675 )
676 .expect("dot ok");
677 assert!(def.nodes.contains_key("node.a"));
678 }
679
680 #[test]
683 fn reject_ungated_feishu_send() {
684 let err = parse_workflow_definition(
685 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1}}}"#,
686 )
687 .unwrap_err();
688 assert!(
689 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
690 "got: {err}"
691 );
692 }
693
694 #[test]
695 fn reject_ungated_feishu_reply() {
696 let err = parse_workflow_definition(
697 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-reply","input":1}}}"#,
698 )
699 .unwrap_err();
700 assert!(
701 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
702 "got: {err}"
703 );
704 }
705
706 #[test]
707 fn reject_ungated_beam_schedule() {
708 let err = parse_workflow_definition(
709 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"beam-schedule","input":1}}}"#,
710 )
711 .unwrap_err();
712 assert!(
713 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
714 "got: {err}"
715 );
716 }
717
718 #[test]
719 fn accept_gated_feishu_send() {
720 let def = parse_workflow_definition(
721 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"humanGate":{"stage":"before","prompt":"ok?"}}}}"#,
722 )
723 .expect("gated feishu-send ok");
724 assert!(def.nodes.contains_key("a"));
725 }
726
727 #[test]
728 fn accept_unsafe_allow_ungated_feishu_send() {
729 let def = parse_workflow_definition(
730 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"unsafeAllowUngated":true}}}"#,
731 )
732 .expect("unsafeAllowUngated ok");
733 assert!(def.nodes.contains_key("a"));
734 }
735
736 #[test]
737 fn accept_ungated_non_side_effect_executor() {
738 let def = parse_workflow_definition(
739 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"custom-tool","input":1}}}"#,
740 )
741 .expect("non-side-effect ok");
742 assert!(def.nodes.contains_key("a"));
743 }
744
745 #[test]
748 fn accept_loop_node_with_minimal_body() {
749 let def = parse_workflow_definition(
751 r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"work"}}}}"#,
752 )
753 .expect("loop accepted");
754 assert!(def.nodes.contains_key("l"));
755 }
756
757 #[test]
758 fn reject_standalone_decision_node() {
759 let err = parse_workflow_definition(
761 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"}}}"#,
762 )
763 .unwrap_err();
764 assert!(
765 err.to_string().contains("standalone"),
766 "expected 'standalone' in error, got: {err}"
767 );
768 }
769
770 #[test]
771 fn ordinary_dag_workflow_accepted() {
772 let def = parse_workflow_definition(
773 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"c":{"type":"subagent","bot":"b","prompt":"q","depends":["a"]}}}"#,
774 )
775 .expect("ordinary DAG ok");
776 assert_eq!(def.nodes.len(), 2);
777 }
778
779 #[test]
781 fn accept_code_review_loop_workflow_json() {
782 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
783 let def = parse_workflow_definition(raw).expect("code-review-loop parsed");
784 assert!(def.nodes.contains_key("review-loop"));
785 assert!(def.nodes.contains_key("implement"));
786 assert!(def.nodes.contains_key("review"));
787 assert!(def.nodes.contains_key("reviewDecision"));
788 }
789
790 #[test]
792 fn accept_subagent_approval_feishu_send_workflow_json() {
793 let raw =
794 include_str!("../../../workflows/subagent-approval-feishu-send.workflow.json");
795 let def = parse_workflow_definition(raw).expect("subagent-approval-feishu-send parsed");
796 assert_eq!(def.workflow_id, "subagent-approval-feishu-send");
797 assert_eq!(def.nodes.len(), 2);
798 assert!(def.nodes.contains_key("draft"));
799 assert!(def.nodes.contains_key("send"));
800 let send_node = def.nodes.get("send").unwrap();
802 match send_node {
803 WorkflowNode::HostExecutor(n) => {
805 assert_eq!(n.executor, "feishu-send");
806 assert_eq!(n.base.depends.as_deref(), Some(vec!["draft".to_string()].as_slice()));
807 assert!(n.base.human_gate.is_some(), "send must have humanGate");
808 assert!(!n.base.unsafe_allow_ungated.unwrap_or(false), "send must NOT use unsafeAllowUngated");
809 }
810 _ => panic!("expected hostExecutor"),
811 }
812 }
813
814 #[test]
818 fn reject_loop_with_missing_body_node() {
819 let err = parse_workflow_definition(
820 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["missing","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
821 )
822 .unwrap_err();
823 assert!(
824 err.to_string().contains("body node") && err.to_string().contains("missing"),
825 "expected body node 'missing' error, got: {err}"
826 );
827 }
828
829 #[test]
831 fn reject_loop_with_nested_loop_body() {
832 let err = parse_workflow_definition(
833 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"loop","maxIterations":2,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
834 )
835 .unwrap_err();
836 assert!(
837 err.to_string().contains("nested loop") || err.to_string().contains("cannot be a Loop"),
838 "expected nested loop error, got: {err}"
839 );
840 }
841
842 #[test]
844 fn reject_loop_with_missing_terminate_node() {
845 let err = parse_workflow_definition(
846 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"nonexistent","via":"humanGate"},"output":{"from":"d"}}}}"#,
847 )
848 .unwrap_err();
849 assert!(
850 err.to_string().contains("terminate.node") && err.to_string().contains("nonexistent"),
851 "expected terminate.node not found error, got: {err}"
852 );
853 }
854
855 #[test]
857 fn reject_loop_with_terminate_node_not_in_body() {
858 let err = parse_workflow_definition(
859 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"x":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"x","via":"humanGate"}}}}"#,
860 )
861 .unwrap_err();
862 assert!(
863 err.to_string().contains("terminate.node") && err.to_string().contains("body"),
864 "expected terminate.node must be in body error, got: {err}"
865 );
866 }
867
868 #[test]
870 fn reject_loop_with_non_decision_terminate_node() {
871 let err = parse_workflow_definition(
872 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["a"],"terminate":{"node":"a","via":"humanGate"}}}}"#,
873 )
874 .unwrap_err();
875 assert!(
876 err.to_string().contains("Decision node"),
877 "expected Decision node error, got: {err}"
878 );
879 }
880
881 #[test]
882 fn reject_loop_with_ungated_decision_terminate_node() {
883 let err = parse_workflow_definition(
884 r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"work"}}}}"#,
885 )
886 .unwrap_err();
887 assert!(err.to_string().contains("humanGate"), "got: {err}");
888 }
889
890 #[test]
891 fn reject_loop_with_non_human_gate_terminate_via() {
892 let err = parse_workflow_definition(
893 r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"automatic"},"output":{"from":"work"}}}}"#,
894 )
895 .unwrap_err();
896 assert!(
897 err.to_string().contains("terminate.via") && err.to_string().contains("humanGate"),
898 "got: {err}"
899 );
900 }
901
902 #[test]
904 fn reject_decision_not_used_by_any_loop() {
905 let err = parse_workflow_definition(
906 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision"}}}"#,
907 )
908 .unwrap_err();
909 assert!(
910 err.to_string().contains("standalone"),
911 "expected standalone Decision error, got: {err}"
912 );
913 }
914
915 #[test]
917 fn reject_decision_outside_loop_body() {
918 let err = parse_workflow_definition(
919 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":[],"terminate":{"node":"d","via":"humanGate"}}}}"#,
920 )
921 .unwrap_err();
922 assert!(
923 err.to_string().contains("body"),
924 "expected body error for terminate.node not in body, got: {err}"
925 );
926 }
927
928 #[test]
930 fn reject_decision_used_by_multiple_loops() {
931 let err = parse_workflow_definition(
932 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l1":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}},"l2":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
933 )
934 .unwrap_err();
935 assert!(
936 err.to_string().contains("multiple loops"),
937 "expected 'multiple loops' error, got: {err}"
938 );
939 }
940
941 #[test]
943 fn reject_loop_with_extra_decision_in_body() {
944 let err = parse_workflow_definition(
945 r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision"},"d2":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["d1","d2"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"d1"}}}}"#,
946 )
947 .unwrap_err();
948 assert!(
949 err.to_string().contains("at most one Decision"),
950 "expected 'at most one Decision' error, got: {err}"
951 );
952 }
953
954 #[test]
956 fn reject_body_node_with_undeclared_external_dependency() {
957 let err = parse_workflow_definition(
958 r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p","depends":["ext"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
959 )
960 .unwrap_err();
961 assert!(
962 err.to_string().contains("external") && err.to_string().contains("depends"),
963 "expected external dep must be declared in loop.depends error, got: {err}"
964 );
965 }
966
967 #[test]
968 fn accept_body_node_with_external_dependency_declared_in_loop_depends() {
969 let def = parse_workflow_definition(
970 r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p","depends":["ext"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["ext"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
971 )
972 .expect("loop with declared external dep accepted");
973 assert!(def.nodes.contains_key("l"));
974 }
975
976 #[test]
978 fn reject_external_node_depending_on_loop_body_node() {
979 let err = parse_workflow_definition(
980 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
981 )
982 .unwrap_err();
983 assert!(
984 err.to_string().contains("depends on loop body node"),
985 "expected depends on loop body node error, got: {err}"
986 );
987 }
988
989 #[test]
990 fn accept_external_node_depending_on_loop_node_instead_of_body_node() {
991 let def = parse_workflow_definition(
992 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["l"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
993 )
994 .expect("external node depends on loop node accepted");
995 assert!(def.nodes.contains_key("l"));
996 }
997
998 #[test]
1001 fn reject_loop_depends_on_own_body_node() {
1002 let err = parse_workflow_definition(
1003 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["inner"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
1004 )
1005 .unwrap_err();
1006 assert!(
1007 err.to_string().contains("body node"),
1008 "expected 'body node' error for loop depending on own body node, got: {err}"
1009 );
1010 }
1011
1012 #[test]
1013 fn reject_loop_depends_on_another_loop_body_node() {
1014 let err = parse_workflow_definition(
1015 r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision"},"a":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["a","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"a"}},"d2":{"type":"decision"},"l2":{"type":"loop","maxIterations":3,"body":["d2"],"depends":["a"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"d2"}}}}"#,
1016 )
1017 .unwrap_err();
1018 assert!(
1019 err.to_string().contains("body node") && err.to_string().contains("'l2'"),
1020 "expected 'body node' error for l2 depending on another loop's body node, got: {err}"
1021 );
1022 }
1023
1024 #[test]
1026 fn accept_loop_depends_on_another_loop_node() {
1027 let def = parse_workflow_definition(
1028 r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["a"]},"a":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["a","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"a"}},"b":{"type":"subagent","bot":"b","prompt":"q"},"d2":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["b"]},"l2":{"type":"loop","maxIterations":3,"body":["b","d2"],"depends":["l1"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"b"}}}}"#,
1029 )
1030 .expect("loop depends on another loop node accepted");
1031 assert!(def.nodes.contains_key("l1"));
1032 assert!(def.nodes.contains_key("l2"));
1033 }
1034
1035 #[test]
1037 fn reject_sink_loop_without_output_from() {
1038 let err = parse_workflow_definition(
1039 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"}}}}"#,
1040 )
1041 .unwrap_err();
1042 assert!(
1043 err.to_string().contains("output.from"),
1044 "expected 'output.from' error for sink loop, got: {err}"
1045 );
1046 }
1047
1048 #[test]
1049 fn accept_sink_loop_with_output_from() {
1050 let def = parse_workflow_definition(
1051 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
1052 )
1053 .expect("sink loop with output.from accepted");
1054 assert!(def.nodes.contains_key("l"));
1055 }
1056
1057 #[test]
1058 fn reject_sink_loop_with_unknown_output_from() {
1059 let err = parse_workflow_definition(
1060 r#"{"workflowId":"f","version":1,"nodes":{"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"missing"}}}}"#,
1061 )
1062 .unwrap_err();
1063 assert!(err.to_string().contains("loop body"), "got: {err}");
1064 }
1065
1066 #[test]
1067 fn reject_sink_loop_with_external_output_from() {
1068 let err = parse_workflow_definition(
1069 r#"{"workflowId":"f","version":1,"nodes":{"external":{"type":"subagent","bot":"b","prompt":"p"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"external"}}}}"#,
1070 )
1071 .unwrap_err();
1072 assert!(err.to_string().contains("loop body"), "got: {err}");
1073 }
1074
1075 #[test]
1076 fn reject_sink_loop_with_decision_output_from() {
1077 let err = parse_workflow_definition(
1078 r#"{"workflowId":"f","version":1,"nodes":{"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
1079 )
1080 .unwrap_err();
1081 assert!(err.to_string().contains("output-producing"), "got: {err}");
1082 }
1083
1084 #[test]
1085 fn accept_non_sink_loop_without_output_from() {
1086 let def = parse_workflow_definition(
1088 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"}},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["l"]}}}"#,
1089 )
1090 .expect("non-sink loop without output.from accepted");
1091 assert!(def.nodes.contains_key("l"));
1092 }
1093
1094 #[test]
1096 fn accept_loop_with_explicit_depends_and_no_body_external_deps() {
1097 let def = parse_workflow_definition(
1098 r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["ext"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
1099 )
1100 .expect("loop with explicit depends accepted");
1101 assert!(def.nodes.contains_key("l"));
1102 }
1103
1104 #[test]
1106 fn accept_two_independent_loops() {
1107 let def = parse_workflow_definition(
1108 r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner1"]},"inner1":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["inner1","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"inner1"}},"d2":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner2"]},"inner2":{"type":"subagent","bot":"b","prompt":"p"},"l2":{"type":"loop","maxIterations":3,"body":["inner2","d2"],"depends":["l1"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"inner2"}}}}"#,
1109 )
1110 .expect("two independent loops accepted");
1111 assert!(def.nodes.contains_key("l1"));
1112 assert!(def.nodes.contains_key("l2"));
1113 }
1114}