Skip to main content

beam_core/
workflow_definition.rs

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
165/// Side-effect executors that MUST be gated (humanGate or unsafeAllowUngated).
166const 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        // Node id must match ^[A-Za-z0-9_.-]+$ (non-empty)
184        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        // Preserve existing path-traversal rejection
197        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                // Side-effect executors must be gated
208                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    // Loop definition validation (Task 8.3)
235    validate_loop_definitions(def)?;
236
237    // Check for at least one scheduler-visible root node
238    // (exclude body nodes and Decision nodes — they're dispatched inside their loop context)
239    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
302/// Build a map from each body node id → its owning loop node id.
303fn 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
315/// Validate loop definitions per Task 8.3 rules:
316/// 1. body nodes must exist
317/// 2. body nodes cannot be loops (no nested loops)
318/// 3. terminate.node must exist, be in body, be a human-gated Decision, and
319///    terminate.via must be "humanGate"; all Decisions must have a loop owner
320/// 4. each loop body can have at most one Decision (the terminate.node)
321/// 5. body external deps must appear in loop.depends
322/// 6. external nodes cannot depend on loop body nodes
323/// 7. output.from must identify an output-producing body node
324/// 8. sink loops must declare output.from
325fn validate_loop_definitions(def: &WorkflowDefinition) -> Result<()> {
326    let body_owner = build_body_owner_map(def);
327
328    // Track which Decision node is owned by which loop (via terminate.node)
329    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        // Rule 1: body nodes must exist in workflow nodes
338        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        // Rule 2: body nodes cannot be Loop (no nested loops)
349        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        // Rule 3a: terminate.node must exist
360        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        // Rule 3a: terminate.node must be in the loop body
370        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        // Rule 3a: terminate.node must be a Decision node. Gate details are
379        // checked after structural validation to preserve useful diagnostics.
380        match def.nodes.get(term_node_id) {
381            Some(WorkflowNode::Decision(_)) => {
382                // Each Decision can belong to at most one loop
383                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        // Rule 4: each loop body can have at most one Decision (the terminate.node)
405        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        // Rule 5: body external deps must be declared in loop.depends
419        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                        // dep is external to the loop body
430                        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    // Rule 3b: all Decision nodes must be owned by some loop (no standalone Decision)
446    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    // Rule 6: non-body nodes (including loop nodes themselves) cannot depend on
458    // loop body nodes.  External nodes that need a loop's result must depend on
459    // the loop node itself, not on individual body nodes.
460    for (node_id, node) in &def.nodes {
461        if body_owner.contains_key(node_id) {
462            continue; // skip body nodes themselves
463        }
464        if matches!(node, WorkflowNode::Decision(_)) {
465            continue; // Decision nodes not owned by a loop are already rejected by rule 3b;
466            // owned Decision nodes are body nodes (skip above)
467        }
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    // Rule 7: every declared output must come from an output-producing body node.
483    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    // Rule 8: sink loops must declare output.from
510    // A loop is a "sink" if no non-body, non-decision node depends on it.
511    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    // Loop termination is only implemented through a human gate.
524    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
551/// Find sink nodes excluding body nodes and Decision nodes.
552/// A node is a sink if no non-body, non-decision node depends on it.
553fn 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    // -- Task 1.1: node id validation --
605
606    #[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    // -- Task 1.2: side-effect executor gate validation --
679
680    #[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    // -- Task 8.2: loop nodes are now accepted (validation deferred to Task 8.3) --
744
745    #[test]
746    fn accept_loop_node_with_minimal_body() {
747        // Sink loops need both an output-producing body node and a gated decision.
748        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        // Task 8.3: standalone Decision nodes (not used as a loop terminate.node) are rejected
758        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    // -- Task 8.2: code-review-loop workflow now parses successfully --
778    #[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    // -- Task 9.2: subagent-approval-feishu-send example parses --
789    #[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        // send depends on draft
798        let send_node = def.nodes.get("send").unwrap();
799        match send_node {
800            // send is a gated feishu-send: humanGate present, unsafeAllowUngated absent
801            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    // -- Task 8.3: loop definition validation --
818
819    // Rule 1: body nodes must exist
820    #[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    // Rule 2: body node cannot be a loop (no nested loops)
833    #[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    // Rule 3a: terminate.node must exist
846    #[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    // Rule 3a: terminate.node must be in loop body
859    #[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    // Rule 3a: terminate.node must be a Decision node
872    #[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    // Rule 3b: Decision node cannot be standalone
906    #[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    // Rule 3b: Decision node must be in body of the loop that uses it
919    #[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    // Rule 3b: same Decision cannot be used by multiple loops
932    #[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    // Rule 4: each loop body can have at most one Decision (the terminate.node)
945    #[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    // Rule 5: body external deps must appear in loop.depends
958    #[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    // Rule 6: external nodes cannot depend on loop body nodes
980    #[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    // Rule 6 (cont): loop node itself must not depend on body nodes
1002    // (either its own body or another loop's body)
1003    #[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    // Also verify that loop depends on another loop (not body node) is still accepted
1028    #[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    // Rule 8: sink loop must declare output.from
1039    #[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        // Loop is not a sink because external node 'ext' depends on it
1090        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    // Edge case: loop with depends on external node but no body external deps (valid)
1098    #[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    // Regression: multiple loops in same workflow
1108    #[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}