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!("nodeId '' rejected: must match ^[A-Za-z0-9_.-]+$");
186 }
187 if !node_id
188 .chars()
189 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
190 {
191 anyhow::bail!(
192 "nodeId '{}' rejected: must match ^[A-Za-z0-9_.-]+$",
193 node_id
194 );
195 }
196 if node_id == "." || node_id == ".." || node_id.contains("..") {
198 anyhow::bail!(
199 "nodeId '{}' rejected: path-traversal style ids are not allowed",
200 node_id
201 );
202 }
203 }
204 for (node_id, node) in &def.nodes {
205 match node {
206 WorkflowNode::HostExecutor(host) => {
207 if is_side_effect_executor(&host.executor)
209 && host.base.human_gate.is_none()
210 && !host.base.unsafe_allow_ungated.unwrap_or(false)
211 {
212 anyhow::bail!(
213 "nodeId '{}': side-effect executor '{}' must have a humanGate or set unsafeAllowUngated: true",
214 node_id,
215 host.executor
216 );
217 }
218 }
219 _ => {}
220 }
221 }
222 for (node_id, node) in &def.nodes {
223 for dep in node_depends(node) {
224 if !def.nodes.contains_key(dep) {
225 anyhow::bail!("Node '{}' depends on unknown node '{}'", node_id, dep);
226 }
227 if dep == node_id {
228 anyhow::bail!("Node '{}' depends on itself", node_id);
229 }
230 }
231 }
232 detect_cycles(def)?;
233
234 validate_loop_definitions(def)?;
236
237 let body_owner = build_body_owner_map(def);
240 let has_root = def.nodes.iter().any(|(node_id, node)| {
241 if body_owner.contains_key(node_id) {
242 return false;
243 }
244 if matches!(node, WorkflowNode::Decision(_)) {
245 return false;
246 }
247 node_depends(node).is_empty()
248 });
249 if !has_root {
250 anyhow::bail!(
251 "Workflow has no scheduler-visible root node (every non-loop-body, non-decision node has dependencies)"
252 );
253 }
254 Ok(())
255}
256
257fn node_depends(node: &WorkflowNode) -> &[String] {
258 match node {
259 WorkflowNode::Subagent(n) => n.base.depends.as_deref().unwrap_or(&[]),
260 WorkflowNode::HostExecutor(n) => n.base.depends.as_deref().unwrap_or(&[]),
261 WorkflowNode::Loop(n) => n.base.depends.as_deref().unwrap_or(&[]),
262 WorkflowNode::Decision(n) => n.base.depends.as_deref().unwrap_or(&[]),
263 }
264}
265
266fn detect_cycles(def: &WorkflowDefinition) -> Result<()> {
267 #[derive(Clone, Copy, PartialEq, Eq)]
268 enum Mark {
269 Temp,
270 Perm,
271 }
272 fn visit(
273 node_id: &str,
274 def: &WorkflowDefinition,
275 marks: &mut BTreeMap<String, Mark>,
276 stack: &mut BTreeSet<String>,
277 ) -> Result<()> {
278 if matches!(marks.get(node_id), Some(Mark::Perm)) {
279 return Ok(());
280 }
281 if matches!(marks.get(node_id), Some(Mark::Temp)) {
282 anyhow::bail!("Workflow graph contains a cycle at '{}'", node_id);
283 }
284 marks.insert(node_id.to_string(), Mark::Temp);
285 stack.insert(node_id.to_string());
286 for dep in node_depends(def.nodes.get(node_id).expect("node must exist")) {
287 visit(dep, def, marks, stack)?;
288 }
289 stack.remove(node_id);
290 marks.insert(node_id.to_string(), Mark::Perm);
291 Ok(())
292 }
293
294 let mut marks = BTreeMap::new();
295 let mut stack = BTreeSet::new();
296 for node_id in def.nodes.keys() {
297 visit(node_id, def, &mut marks, &mut stack)?;
298 }
299 Ok(())
300}
301
302fn build_body_owner_map(def: &WorkflowDefinition) -> HashMap<String, String> {
304 let mut owner = HashMap::new();
305 for (loop_id, node) in &def.nodes {
306 if let WorkflowNode::Loop(loop_node) = node {
307 for body_id in &loop_node.body {
308 owner.insert(body_id.clone(), loop_id.clone());
309 }
310 }
311 }
312 owner
313}
314
315fn validate_loop_definitions(def: &WorkflowDefinition) -> Result<()> {
326 let body_owner = build_body_owner_map(def);
327
328 let mut decision_loop_owner: HashMap<String, String> = HashMap::new();
330
331 for (loop_id, node) in &def.nodes {
332 let loop_node = match node {
333 WorkflowNode::Loop(ln) => ln,
334 _ => continue,
335 };
336
337 for body_id in &loop_node.body {
339 if !def.nodes.contains_key(body_id) {
340 anyhow::bail!(
341 "loop '{}' body node '{}' not found in workflow nodes",
342 loop_id,
343 body_id
344 );
345 }
346 }
347
348 for body_id in &loop_node.body {
350 if matches!(def.nodes.get(body_id), Some(WorkflowNode::Loop(_))) {
351 anyhow::bail!(
352 "loop '{}' body node '{}' cannot be a Loop node (nested loops are not supported)",
353 loop_id,
354 body_id
355 );
356 }
357 }
358
359 let term_node_id = &loop_node.terminate.node;
361 if !def.nodes.contains_key(term_node_id) {
362 anyhow::bail!(
363 "loop '{}' terminate.node '{}' not found in workflow nodes",
364 loop_id,
365 term_node_id
366 );
367 }
368
369 if !loop_node.body.contains(term_node_id) {
371 anyhow::bail!(
372 "loop '{}' terminate.node '{}' must be in the loop body",
373 loop_id,
374 term_node_id
375 );
376 }
377
378 match def.nodes.get(term_node_id) {
381 Some(WorkflowNode::Decision(_)) => {
382 if let Some(existing_owner) =
384 decision_loop_owner.insert(term_node_id.clone(), loop_id.clone())
385 {
386 anyhow::bail!(
387 "Decision node '{}' is used as terminate.node by multiple loops: '{}' and '{}'",
388 term_node_id,
389 existing_owner,
390 loop_id
391 );
392 }
393 }
394 _ => {
395 anyhow::bail!(
396 "loop '{}' terminate.node '{}' must be a Decision node, got {:?}",
397 loop_id,
398 term_node_id,
399 std::mem::discriminant(def.nodes.get(term_node_id).unwrap())
400 );
401 }
402 }
403
404 for body_id in &loop_node.body {
406 if body_id != term_node_id
407 && matches!(def.nodes.get(body_id), Some(WorkflowNode::Decision(_)))
408 {
409 anyhow::bail!(
410 "loop '{}' has Decision node '{}' in body that is not the terminate.node; \
411 each loop body can have at most one Decision node (which must be the terminate.node)",
412 loop_id,
413 body_id
414 );
415 }
416 }
417
418 let loop_depends: BTreeSet<&str> = loop_node
420 .base
421 .depends
422 .as_ref()
423 .map(|v| v.iter().map(String::as_str).collect())
424 .unwrap_or_default();
425 for body_id in &loop_node.body {
426 if let Some(body_node) = def.nodes.get(body_id) {
427 for dep in node_depends(body_node) {
428 if !loop_node.body.contains(dep) {
429 if !loop_depends.contains(dep.as_str()) {
431 anyhow::bail!(
432 "loop '{}' body node '{}' depends on external node '{}'; \
433 all external dependencies of body nodes must be declared in the loop's depends",
434 loop_id,
435 body_id,
436 dep
437 );
438 }
439 }
440 }
441 }
442 }
443 }
444
445 for (node_id, node) in &def.nodes {
447 if matches!(node, WorkflowNode::Decision(_)) {
448 if !decision_loop_owner.contains_key(node_id) {
449 anyhow::bail!(
450 "Decision node '{}' is standalone; Decision nodes must be used as a loop's terminate.node and reside in that loop's body",
451 node_id
452 );
453 }
454 }
455 }
456
457 for (node_id, node) in &def.nodes {
461 if body_owner.contains_key(node_id) {
462 continue; }
464 if matches!(node, WorkflowNode::Decision(_)) {
465 continue; }
468 for dep in node_depends(node) {
469 if body_owner.contains_key(dep) {
470 let owner = body_owner.get(dep).unwrap();
471 anyhow::bail!(
472 "node '{}' depends on loop body node '{}'; \
473 nodes must depend on the loop node '{}' instead of its body node",
474 node_id,
475 dep,
476 owner
477 );
478 }
479 }
480 }
481
482 for (loop_id, node) in &def.nodes {
484 let WorkflowNode::Loop(loop_node) = node else {
485 continue;
486 };
487 let Some(output) = &loop_node.output else {
488 continue;
489 };
490 if !loop_node.body.contains(&output.from) {
491 anyhow::bail!(
492 "loop '{}' output.from '{}' must identify a node in the loop body",
493 loop_id,
494 output.from
495 );
496 }
497 if !matches!(
498 def.nodes.get(&output.from),
499 Some(WorkflowNode::Subagent(_) | WorkflowNode::HostExecutor(_))
500 ) {
501 anyhow::bail!(
502 "loop '{}' output.from '{}' must identify an output-producing Subagent or HostExecutor body node",
503 loop_id,
504 output.from
505 );
506 }
507 }
508
509 let sinks = find_non_body_sinks(def, &body_owner);
512 for sink_id in &sinks {
513 if let Some(WorkflowNode::Loop(loop_node)) = def.nodes.get(sink_id) {
514 if loop_node.output.is_none() {
515 anyhow::bail!(
516 "sink loop '{}' must declare output.from (the loop is not depended on by any external node)",
517 sink_id
518 );
519 }
520 }
521 }
522
523 for (loop_id, node) in &def.nodes {
525 let WorkflowNode::Loop(loop_node) = node else {
526 continue;
527 };
528 if loop_node.terminate.via != "humanGate" {
529 anyhow::bail!(
530 "loop '{}' terminate.via must be 'humanGate', got '{}'",
531 loop_id,
532 loop_node.terminate.via
533 );
534 }
535 let decision = match def.nodes.get(&loop_node.terminate.node) {
536 Some(WorkflowNode::Decision(decision)) => decision,
537 _ => continue,
538 };
539 if decision.base.human_gate.is_none() {
540 anyhow::bail!(
541 "loop '{}' terminate.node '{}' must declare humanGate",
542 loop_id,
543 loop_node.terminate.node
544 );
545 }
546 }
547
548 Ok(())
549}
550
551fn find_non_body_sinks(
554 def: &WorkflowDefinition,
555 body_owner: &HashMap<String, String>,
556) -> Vec<String> {
557 let mut referenced: BTreeSet<String> = BTreeSet::new();
558 for (node_id, node) in &def.nodes {
559 if body_owner.contains_key(node_id) {
560 continue;
561 }
562 if matches!(node, WorkflowNode::Decision(_)) {
563 continue;
564 }
565 for dep in node_depends(node) {
566 referenced.insert(dep.clone());
567 }
568 }
569 def.nodes
570 .iter()
571 .filter_map(|(node_id, node)| {
572 if body_owner.contains_key(node_id) {
573 return None;
574 }
575 if matches!(node, WorkflowNode::Decision(_)) {
576 return None;
577 }
578 (!referenced.contains(node_id)).then_some(node_id.clone())
579 })
580 .collect()
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn parse_workflow_definition_accepts_basic_graph() {
589 let def = parse_workflow_definition(
590 r#"{
591 "workflowId":"flow-a",
592 "version":1,
593 "nodes":{
594 "a":{"type":"subagent","bot":"bot-a","prompt":"hi"},
595 "b":{"type":"hostExecutor","executor":"feishu-send","input":1,"depends":["a"],"humanGate":{"stage":"before","prompt":"approve?"}}
596 }
597 }"#,
598 )
599 .expect("definition");
600 assert_eq!(def.workflow_id, "flow-a");
601 assert_eq!(def.nodes.len(), 2);
602 }
603
604 #[test]
607 fn reject_node_id_with_slash() {
608 let err = parse_workflow_definition(
609 r#"{"workflowId":"f","version":1,"nodes":{"node/a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
610 )
611 .unwrap_err();
612 assert!(err.to_string().contains("nodeId"), "got: {err}");
613 }
614
615 #[test]
616 fn reject_node_id_with_space() {
617 let err = parse_workflow_definition(
618 r#"{"workflowId":"f","version":1,"nodes":{"node a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
619 )
620 .unwrap_err();
621 assert!(err.to_string().contains("nodeId"), "got: {err}");
622 }
623
624 #[test]
625 fn reject_node_id_dotdot() {
626 let err = parse_workflow_definition(
627 r#"{"workflowId":"f","version":1,"nodes":{"..":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
628 )
629 .unwrap_err();
630 assert!(err.to_string().contains("nodeId"), "got: {err}");
631 }
632
633 #[test]
634 fn reject_node_id_containing_dotdot() {
635 let err = parse_workflow_definition(
636 r#"{"workflowId":"f","version":1,"nodes":{"a..b":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
637 )
638 .unwrap_err();
639 assert!(err.to_string().contains("nodeId"), "got: {err}");
640 }
641
642 #[test]
643 fn reject_empty_node_id() {
644 let err = parse_workflow_definition(
645 r#"{"workflowId":"f","version":1,"nodes":{"":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
646 )
647 .unwrap_err();
648 assert!(err.to_string().contains("nodeId"), "got: {err}");
649 }
650
651 #[test]
652 fn accept_node_id_with_dash() {
653 let def = parse_workflow_definition(
654 r#"{"workflowId":"f","version":1,"nodes":{"node-a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
655 )
656 .expect("dash ok");
657 assert!(def.nodes.contains_key("node-a"));
658 }
659
660 #[test]
661 fn accept_node_id_with_underscore() {
662 let def = parse_workflow_definition(
663 r#"{"workflowId":"f","version":1,"nodes":{"node_a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
664 )
665 .expect("underscore ok");
666 assert!(def.nodes.contains_key("node_a"));
667 }
668
669 #[test]
670 fn accept_node_id_with_dot() {
671 let def = parse_workflow_definition(
672 r#"{"workflowId":"f","version":1,"nodes":{"node.a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
673 )
674 .expect("dot ok");
675 assert!(def.nodes.contains_key("node.a"));
676 }
677
678 #[test]
681 fn reject_ungated_feishu_send() {
682 let err = parse_workflow_definition(
683 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1}}}"#,
684 )
685 .unwrap_err();
686 assert!(
687 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
688 "got: {err}"
689 );
690 }
691
692 #[test]
693 fn reject_ungated_feishu_reply() {
694 let err = parse_workflow_definition(
695 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-reply","input":1}}}"#,
696 )
697 .unwrap_err();
698 assert!(
699 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
700 "got: {err}"
701 );
702 }
703
704 #[test]
705 fn reject_ungated_beam_schedule() {
706 let err = parse_workflow_definition(
707 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"beam-schedule","input":1}}}"#,
708 )
709 .unwrap_err();
710 assert!(
711 err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
712 "got: {err}"
713 );
714 }
715
716 #[test]
717 fn accept_gated_feishu_send() {
718 let def = parse_workflow_definition(
719 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"humanGate":{"stage":"before","prompt":"ok?"}}}}"#,
720 )
721 .expect("gated feishu-send ok");
722 assert!(def.nodes.contains_key("a"));
723 }
724
725 #[test]
726 fn accept_unsafe_allow_ungated_feishu_send() {
727 let def = parse_workflow_definition(
728 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"unsafeAllowUngated":true}}}"#,
729 )
730 .expect("unsafeAllowUngated ok");
731 assert!(def.nodes.contains_key("a"));
732 }
733
734 #[test]
735 fn accept_ungated_non_side_effect_executor() {
736 let def = parse_workflow_definition(
737 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"custom-tool","input":1}}}"#,
738 )
739 .expect("non-side-effect ok");
740 assert!(def.nodes.contains_key("a"));
741 }
742
743 #[test]
746 fn accept_loop_node_with_minimal_body() {
747 let def = parse_workflow_definition(
749 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"}}}}"#,
750 )
751 .expect("loop accepted");
752 assert!(def.nodes.contains_key("l"));
753 }
754
755 #[test]
756 fn reject_standalone_decision_node() {
757 let err = parse_workflow_definition(
759 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"}}}"#,
760 )
761 .unwrap_err();
762 assert!(
763 err.to_string().contains("standalone"),
764 "expected 'standalone' in error, got: {err}"
765 );
766 }
767
768 #[test]
769 fn ordinary_dag_workflow_accepted() {
770 let def = parse_workflow_definition(
771 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"c":{"type":"subagent","bot":"b","prompt":"q","depends":["a"]}}}"#,
772 )
773 .expect("ordinary DAG ok");
774 assert_eq!(def.nodes.len(), 2);
775 }
776
777 #[test]
779 fn accept_code_review_loop_workflow_json() {
780 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
781 let def = parse_workflow_definition(raw).expect("code-review-loop parsed");
782 assert!(def.nodes.contains_key("review-loop"));
783 assert!(def.nodes.contains_key("implement"));
784 assert!(def.nodes.contains_key("review"));
785 assert!(def.nodes.contains_key("reviewDecision"));
786 }
787
788 #[test]
790 fn accept_subagent_approval_feishu_send_workflow_json() {
791 let raw = include_str!("../../../workflows/subagent-approval-feishu-send.workflow.json");
792 let def = parse_workflow_definition(raw).expect("subagent-approval-feishu-send parsed");
793 assert_eq!(def.workflow_id, "subagent-approval-feishu-send");
794 assert_eq!(def.nodes.len(), 2);
795 assert!(def.nodes.contains_key("draft"));
796 assert!(def.nodes.contains_key("send"));
797 let send_node = def.nodes.get("send").unwrap();
799 match send_node {
800 WorkflowNode::HostExecutor(n) => {
802 assert_eq!(n.executor, "feishu-send");
803 assert_eq!(
804 n.base.depends.as_deref(),
805 Some(vec!["draft".to_string()].as_slice())
806 );
807 assert!(n.base.human_gate.is_some(), "send must have humanGate");
808 assert!(
809 !n.base.unsafe_allow_ungated.unwrap_or(false),
810 "send must NOT use unsafeAllowUngated"
811 );
812 }
813 _ => panic!("expected hostExecutor"),
814 }
815 }
816
817 #[test]
821 fn reject_loop_with_missing_body_node() {
822 let err = parse_workflow_definition(
823 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"}}}}"#,
824 )
825 .unwrap_err();
826 assert!(
827 err.to_string().contains("body node") && err.to_string().contains("missing"),
828 "expected body node 'missing' error, got: {err}"
829 );
830 }
831
832 #[test]
834 fn reject_loop_with_nested_loop_body() {
835 let err = parse_workflow_definition(
836 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"}}}}"#,
837 )
838 .unwrap_err();
839 assert!(
840 err.to_string().contains("nested loop") || err.to_string().contains("cannot be a Loop"),
841 "expected nested loop error, got: {err}"
842 );
843 }
844
845 #[test]
847 fn reject_loop_with_missing_terminate_node() {
848 let err = parse_workflow_definition(
849 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"nonexistent","via":"humanGate"},"output":{"from":"d"}}}}"#,
850 )
851 .unwrap_err();
852 assert!(
853 err.to_string().contains("terminate.node") && err.to_string().contains("nonexistent"),
854 "expected terminate.node not found error, got: {err}"
855 );
856 }
857
858 #[test]
860 fn reject_loop_with_terminate_node_not_in_body() {
861 let err = parse_workflow_definition(
862 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"}}}}"#,
863 )
864 .unwrap_err();
865 assert!(
866 err.to_string().contains("terminate.node") && err.to_string().contains("body"),
867 "expected terminate.node must be in body error, got: {err}"
868 );
869 }
870
871 #[test]
873 fn reject_loop_with_non_decision_terminate_node() {
874 let err = parse_workflow_definition(
875 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"}}}}"#,
876 )
877 .unwrap_err();
878 assert!(
879 err.to_string().contains("Decision node"),
880 "expected Decision node error, got: {err}"
881 );
882 }
883
884 #[test]
885 fn reject_loop_with_ungated_decision_terminate_node() {
886 let err = parse_workflow_definition(
887 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"}}}}"#,
888 )
889 .unwrap_err();
890 assert!(err.to_string().contains("humanGate"), "got: {err}");
891 }
892
893 #[test]
894 fn reject_loop_with_non_human_gate_terminate_via() {
895 let err = parse_workflow_definition(
896 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"}}}}"#,
897 )
898 .unwrap_err();
899 assert!(
900 err.to_string().contains("terminate.via") && err.to_string().contains("humanGate"),
901 "got: {err}"
902 );
903 }
904
905 #[test]
907 fn reject_decision_not_used_by_any_loop() {
908 let err = parse_workflow_definition(
909 r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision"}}}"#,
910 )
911 .unwrap_err();
912 assert!(
913 err.to_string().contains("standalone"),
914 "expected standalone Decision error, got: {err}"
915 );
916 }
917
918 #[test]
920 fn reject_decision_outside_loop_body() {
921 let err = parse_workflow_definition(
922 r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":[],"terminate":{"node":"d","via":"humanGate"}}}}"#,
923 )
924 .unwrap_err();
925 assert!(
926 err.to_string().contains("body"),
927 "expected body error for terminate.node not in body, got: {err}"
928 );
929 }
930
931 #[test]
933 fn reject_decision_used_by_multiple_loops() {
934 let err = parse_workflow_definition(
935 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"}}}}"#,
936 )
937 .unwrap_err();
938 assert!(
939 err.to_string().contains("multiple loops"),
940 "expected 'multiple loops' error, got: {err}"
941 );
942 }
943
944 #[test]
946 fn reject_loop_with_extra_decision_in_body() {
947 let err = parse_workflow_definition(
948 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"}}}}"#,
949 )
950 .unwrap_err();
951 assert!(
952 err.to_string().contains("at most one Decision"),
953 "expected 'at most one Decision' error, got: {err}"
954 );
955 }
956
957 #[test]
959 fn reject_body_node_with_undeclared_external_dependency() {
960 let err = parse_workflow_definition(
961 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"}}}}"#,
962 )
963 .unwrap_err();
964 assert!(
965 err.to_string().contains("external") && err.to_string().contains("depends"),
966 "expected external dep must be declared in loop.depends error, got: {err}"
967 );
968 }
969
970 #[test]
971 fn accept_body_node_with_external_dependency_declared_in_loop_depends() {
972 let def = parse_workflow_definition(
973 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"}}}}"#,
974 )
975 .expect("loop with declared external dep accepted");
976 assert!(def.nodes.contains_key("l"));
977 }
978
979 #[test]
981 fn reject_external_node_depending_on_loop_body_node() {
982 let err = parse_workflow_definition(
983 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"}}}}"#,
984 )
985 .unwrap_err();
986 assert!(
987 err.to_string().contains("depends on loop body node"),
988 "expected depends on loop body node error, got: {err}"
989 );
990 }
991
992 #[test]
993 fn accept_external_node_depending_on_loop_node_instead_of_body_node() {
994 let def = parse_workflow_definition(
995 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"}}}}"#,
996 )
997 .expect("external node depends on loop node accepted");
998 assert!(def.nodes.contains_key("l"));
999 }
1000
1001 #[test]
1004 fn reject_loop_depends_on_own_body_node() {
1005 let err = parse_workflow_definition(
1006 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"}}}}"#,
1007 )
1008 .unwrap_err();
1009 assert!(
1010 err.to_string().contains("body node"),
1011 "expected 'body node' error for loop depending on own body node, got: {err}"
1012 );
1013 }
1014
1015 #[test]
1016 fn reject_loop_depends_on_another_loop_body_node() {
1017 let err = parse_workflow_definition(
1018 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"}}}}"#,
1019 )
1020 .unwrap_err();
1021 assert!(
1022 err.to_string().contains("body node") && err.to_string().contains("'l2'"),
1023 "expected 'body node' error for l2 depending on another loop's body node, got: {err}"
1024 );
1025 }
1026
1027 #[test]
1029 fn accept_loop_depends_on_another_loop_node() {
1030 let def = parse_workflow_definition(
1031 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"}}}}"#,
1032 )
1033 .expect("loop depends on another loop node accepted");
1034 assert!(def.nodes.contains_key("l1"));
1035 assert!(def.nodes.contains_key("l2"));
1036 }
1037
1038 #[test]
1040 fn reject_sink_loop_without_output_from() {
1041 let err = parse_workflow_definition(
1042 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"}}}}"#,
1043 )
1044 .unwrap_err();
1045 assert!(
1046 err.to_string().contains("output.from"),
1047 "expected 'output.from' error for sink loop, got: {err}"
1048 );
1049 }
1050
1051 #[test]
1052 fn accept_sink_loop_with_output_from() {
1053 let def = parse_workflow_definition(
1054 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"}}}}"#,
1055 )
1056 .expect("sink loop with output.from accepted");
1057 assert!(def.nodes.contains_key("l"));
1058 }
1059
1060 #[test]
1061 fn reject_sink_loop_with_unknown_output_from() {
1062 let err = parse_workflow_definition(
1063 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"}}}}"#,
1064 )
1065 .unwrap_err();
1066 assert!(err.to_string().contains("loop body"), "got: {err}");
1067 }
1068
1069 #[test]
1070 fn reject_sink_loop_with_external_output_from() {
1071 let err = parse_workflow_definition(
1072 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"}}}}"#,
1073 )
1074 .unwrap_err();
1075 assert!(err.to_string().contains("loop body"), "got: {err}");
1076 }
1077
1078 #[test]
1079 fn reject_sink_loop_with_decision_output_from() {
1080 let err = parse_workflow_definition(
1081 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"}}}}"#,
1082 )
1083 .unwrap_err();
1084 assert!(err.to_string().contains("output-producing"), "got: {err}");
1085 }
1086
1087 #[test]
1088 fn accept_non_sink_loop_without_output_from() {
1089 let def = parse_workflow_definition(
1091 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"]}}}"#,
1092 )
1093 .expect("non-sink loop without output.from accepted");
1094 assert!(def.nodes.contains_key("l"));
1095 }
1096
1097 #[test]
1099 fn accept_loop_with_explicit_depends_and_no_body_external_deps() {
1100 let def = parse_workflow_definition(
1101 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"}}}}"#,
1102 )
1103 .expect("loop with explicit depends accepted");
1104 assert!(def.nodes.contains_key("l"));
1105 }
1106
1107 #[test]
1109 fn accept_two_independent_loops() {
1110 let def = parse_workflow_definition(
1111 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"}}}}"#,
1112 )
1113 .expect("two independent loops accepted");
1114 assert!(def.nodes.contains_key("l1"));
1115 assert!(def.nodes.contains_key("l2"));
1116 }
1117}