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!(
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        // Preserve existing path-traversal rejection
199        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                // Side-effect executors must be gated
210                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    // Loop definition validation (Task 8.3)
237    validate_loop_definitions(def)?;
238
239    // Check for at least one scheduler-visible root node
240    // (exclude body nodes and Decision nodes — they're dispatched inside their loop context)
241    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
304/// Build a map from each body node id → its owning loop node id.
305fn 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
317/// Validate loop definitions per Task 8.3 rules:
318/// 1. body nodes must exist
319/// 2. body nodes cannot be loops (no nested loops)
320/// 3. terminate.node must exist, be in body, be a human-gated Decision, and
321///    terminate.via must be "humanGate"; all Decisions must have a loop owner
322/// 4. each loop body can have at most one Decision (the terminate.node)
323/// 5. body external deps must appear in loop.depends
324/// 6. external nodes cannot depend on loop body nodes
325/// 7. output.from must identify an output-producing body node
326/// 8. sink loops must declare output.from
327fn validate_loop_definitions(def: &WorkflowDefinition) -> Result<()> {
328    let body_owner = build_body_owner_map(def);
329
330    // Track which Decision node is owned by which loop (via terminate.node)
331    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        // Rule 1: body nodes must exist in workflow nodes
340        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        // Rule 2: body nodes cannot be Loop (no nested loops)
351        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        // Rule 3a: terminate.node must exist
362        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        // Rule 3a: terminate.node must be in the loop body
372        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        // Rule 3a: terminate.node must be a Decision node. Gate details are
381        // checked after structural validation to preserve useful diagnostics.
382        match def.nodes.get(term_node_id) {
383            Some(WorkflowNode::Decision(_)) => {
384                // Each Decision can belong to at most one loop
385                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        // Rule 4: each loop body can have at most one Decision (the terminate.node)
407        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        // Rule 5: body external deps must be declared in loop.depends
421        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                        // dep is external to the loop body
432                        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    // Rule 3b: all Decision nodes must be owned by some loop (no standalone Decision)
448    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    // Rule 6: non-body nodes (including loop nodes themselves) cannot depend on
460    // loop body nodes.  External nodes that need a loop's result must depend on
461    // the loop node itself, not on individual body nodes.
462    for (node_id, node) in &def.nodes {
463        if body_owner.contains_key(node_id) {
464            continue; // skip body nodes themselves
465        }
466        if matches!(node, WorkflowNode::Decision(_)) {
467            continue; // Decision nodes not owned by a loop are already rejected by rule 3b;
468                      // owned Decision nodes are body nodes (skip above)
469        }
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    // Rule 7: every declared output must come from an output-producing body node.
485    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    // Rule 8: sink loops must declare output.from
512    // A loop is a "sink" if no non-body, non-decision node depends on it.
513    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    // Loop termination is only implemented through a human gate.
526    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
553/// Find sink nodes excluding body nodes and Decision nodes.
554/// A node is a sink if no non-body, non-decision node depends on it.
555fn 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    // -- Task 1.1: node id validation --
607
608    #[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    // -- Task 1.2: side-effect executor gate validation --
681
682    #[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    // -- Task 8.2: loop nodes are now accepted (validation deferred to Task 8.3) --
746
747    #[test]
748    fn accept_loop_node_with_minimal_body() {
749        // Sink loops need both an output-producing body node and a gated decision.
750        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        // Task 8.3: standalone Decision nodes (not used as a loop terminate.node) are rejected
760        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    // -- Task 8.2: code-review-loop workflow now parses successfully --
780    #[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    // -- Task 9.2: subagent-approval-feishu-send example parses --
791    #[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        // send depends on draft
801        let send_node = def.nodes.get("send").unwrap();
802        match send_node {
803            // send is a gated feishu-send: humanGate present, unsafeAllowUngated absent
804            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    // -- Task 8.3: loop definition validation --
815
816    // Rule 1: body nodes must exist
817    #[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    // Rule 2: body node cannot be a loop (no nested loops)
830    #[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    // Rule 3a: terminate.node must exist
843    #[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    // Rule 3a: terminate.node must be in loop body
856    #[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    // Rule 3a: terminate.node must be a Decision node
869    #[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    // Rule 3b: Decision node cannot be standalone
903    #[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    // Rule 3b: Decision node must be in body of the loop that uses it
916    #[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    // Rule 3b: same Decision cannot be used by multiple loops
929    #[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    // Rule 4: each loop body can have at most one Decision (the terminate.node)
942    #[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    // Rule 5: body external deps must appear in loop.depends
955    #[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    // Rule 6: external nodes cannot depend on loop body nodes
977    #[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    // Rule 6 (cont): loop node itself must not depend on body nodes
999    // (either its own body or another loop's body)
1000    #[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    // Also verify that loop depends on another loop (not body node) is still accepted
1025    #[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    // Rule 8: sink loop must declare output.from
1036    #[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        // Loop is not a sink because external node 'ext' depends on it
1087        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    // Edge case: loop with depends on external node but no body external deps (valid)
1095    #[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    // Regression: multiple loops in same workflow
1105    #[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}