1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde_json::Value;
4
5use crate::workflow_snapshot::{ActivityStatus, NodeStatus};
6use crate::{
7 ActivityState, HumanGate, LoopIterationStatus, LoopNode, LoopStatus, NodeState, RunSnapshotDTO,
8 WorkflowDefinition, WorkflowNode, WorkflowOutputRef,
9};
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum OrchestratorAction {
13 DispatchGate {
14 node_id: String,
15 activity_id: String,
16 human_gate: HumanGate,
17 },
18 DispatchWork {
19 node_id: String,
20 activity_id: String,
21 node: WorkflowNode,
22 },
23 CompleteNodeSucceeded {
24 node_id: String,
25 last_activity_id: String,
26 output_ref: Option<WorkflowOutputRef>,
27 },
28 CompleteNodeFailed {
29 node_id: String,
30 last_activity_id: String,
31 error_class: String,
32 },
33 CompleteRunSucceeded {
34 output_ref: WorkflowOutputRef,
35 sink_node_id: String,
36 },
37 CompleteRunFailed {
38 failed_node_id: String,
39 },
40 StartLoop {
42 node_id: String,
43 max_iterations: u64,
44 },
45 StartLoopIteration {
47 node_id: String,
48 iteration: u64,
49 },
50 FinishLoopIteration {
52 node_id: String,
53 iteration: u64,
54 resolution: String,
56 decision_activity_id: Option<String>,
57 wait_resolved_event_id: Option<String>,
58 by: Option<String>,
59 comment: Option<String>,
60 timed_out: Option<bool>,
61 },
62 FinishLoop {
64 node_id: String,
65 final_iteration: u64,
66 resolution: String,
68 output_ref: Option<WorkflowOutputRef>,
69 error_code: Option<String>,
70 error_class: Option<String>,
71 },
72}
73
74impl OrchestratorAction {
75 pub fn is_dispatch(&self) -> bool {
76 matches!(self, Self::DispatchGate { .. } | Self::DispatchWork { .. })
77 }
78}
79
80pub fn decide_next_actions(
81 snapshot: &RunSnapshotDTO,
82 def: &WorkflowDefinition,
83) -> Vec<OrchestratorAction> {
84 if matches!(
85 snapshot.run.status,
86 crate::RunStatus::Succeeded | crate::RunStatus::Failed | crate::RunStatus::Cancelled
87 ) {
88 return Vec::new();
89 }
90 if snapshot.run.cancelled_run_intent.is_some() {
91 return Vec::new();
92 }
93
94 let order = topological_order(def);
95 let body_owner = build_body_owner_map(def);
96 let mut actions = Vec::new();
97 let mut failed_node_id: Option<String> = None;
98 let mut pending_count: usize = 0;
99
100 for node_id in order {
101 if body_owner.contains_key(&node_id) {
102 continue;
103 }
104 let Some(node) = def.nodes.get(&node_id) else {
105 continue;
106 };
107 if let WorkflowNode::Loop(loop_node) = node {
109 let deps_ok = node_depends(node)
110 .iter()
111 .all(|dep| dependency_is_succeeded(snapshot, dep));
112 if !deps_ok {
113 pending_count += 1;
114 continue;
115 }
116 let advance = decide_loop_advancement(snapshot, def, &node_id, loop_node);
117 if advance.is_succeeded {
118 actions.extend(advance.actions);
119 continue;
120 }
121 if advance.is_failed {
122 failed_node_id.get_or_insert(node_id.clone());
123 actions.extend(advance.actions);
124 continue;
125 }
126 if advance.actions.is_empty() {
127 pending_count += 1;
128 } else {
129 actions.extend(advance.actions);
130 }
131 continue;
132 }
133
134 if let Some(node_state) = node_state(snapshot, &node_id) {
135 if matches!(
136 node_state.status,
137 NodeStatus::Succeeded | NodeStatus::Skipped | NodeStatus::Cancelled
138 ) {
139 continue;
140 }
141 if node_state.status == NodeStatus::Failed {
142 failed_node_id.get_or_insert(node_id.clone());
143 continue;
144 }
145 }
146
147 let deps_ok = node_depends(node)
148 .iter()
149 .all(|dep| dependency_is_succeeded(snapshot, dep));
150 if !deps_ok {
151 pending_count += 1;
152 continue;
153 }
154
155 let gate_id = gate_activity_id(&snapshot.run.run_id, &node_id);
156 let work_id = work_activity_id(&snapshot.run.run_id, &node_id);
157 let advance = decide_node_advancement(snapshot, node, &node_id, &gate_id, &work_id);
158 if advance.is_succeeded {
159 actions.extend(advance.actions);
160 continue;
161 }
162 if advance.is_failed {
163 actions.extend(advance.actions);
164 continue;
165 }
166 if advance.actions.is_empty() {
167 pending_count += 1;
168 } else {
169 actions.extend(advance.actions);
170 }
171 }
172
173 if actions.is_empty() {
174 if let Some(node_id) = failed_node_id {
175 return vec![OrchestratorAction::CompleteRunFailed {
176 failed_node_id: node_id,
177 }];
178 }
179 if pending_count == 0 {
180 let sinks = find_sinks(def);
181 if sinks.len() == 1 {
182 let sink_id = sinks[0].clone();
183 let sink_output_id = format!("{}::work::{}", snapshot.run.run_id, sink_id);
184 if let Some(output_ref) = snapshot.outputs.get(&sink_output_id) {
185 return vec![OrchestratorAction::CompleteRunSucceeded {
186 output_ref: output_ref.clone(),
187 sink_node_id: sink_id,
188 }];
189 }
190 }
191 }
192 }
193
194 actions
195}
196
197#[derive(Debug, Clone, PartialEq)]
198struct AdvanceDecision {
199 actions: Vec<OrchestratorAction>,
200 is_succeeded: bool,
201 is_failed: bool,
202}
203
204fn decide_node_advancement(
205 snapshot: &RunSnapshotDTO,
206 node: &WorkflowNode,
207 node_id: &str,
208 gate_activity_id: &str,
209 work_activity_id: &str,
210) -> AdvanceDecision {
211 match node {
212 WorkflowNode::Decision(node) => {
213 let Some(gate) = activity_state(snapshot, gate_activity_id) else {
214 let Some(gate_cfg) = node.base.human_gate.as_ref() else {
215 return AdvanceDecision {
216 actions: Vec::new(),
217 is_succeeded: false,
218 is_failed: false,
219 };
220 };
221 return AdvanceDecision {
222 actions: vec![OrchestratorAction::DispatchGate {
223 node_id: node_id.to_string(),
224 activity_id: gate_activity_id.to_string(),
225 human_gate: gate_cfg.clone(),
226 }],
227 is_succeeded: false,
228 is_failed: false,
229 };
230 };
231 match gate.status {
232 ActivityStatus::Succeeded => AdvanceDecision {
233 actions: vec![OrchestratorAction::CompleteNodeSucceeded {
234 node_id: node_id.to_string(),
235 last_activity_id: gate_activity_id.to_string(),
236 output_ref: None,
237 }],
238 is_succeeded: true,
239 is_failed: false,
240 },
241 ActivityStatus::Failed | ActivityStatus::TimedOut => AdvanceDecision {
242 actions: vec![OrchestratorAction::CompleteNodeFailed {
243 node_id: node_id.to_string(),
244 last_activity_id: gate_activity_id.to_string(),
245 error_class: if gate.status == ActivityStatus::TimedOut {
246 "userFault".to_string()
247 } else {
248 derive_error_class(gate)
249 },
250 }],
251 is_succeeded: false,
252 is_failed: true,
253 },
254 _ => AdvanceDecision {
255 actions: Vec::new(),
256 is_succeeded: false,
257 is_failed: false,
258 },
259 }
260 }
261 WorkflowNode::Loop(_) => AdvanceDecision {
262 actions: Vec::new(),
263 is_succeeded: false,
264 is_failed: false,
265 },
266 WorkflowNode::Subagent(node) => decide_plain_node(
267 snapshot,
268 node.base.human_gate.as_ref(),
269 node_id,
270 gate_activity_id,
271 work_activity_id,
272 WorkflowNode::Subagent(node.clone()),
273 ),
274 WorkflowNode::HostExecutor(node) => decide_plain_node(
275 snapshot,
276 node.base.human_gate.as_ref(),
277 node_id,
278 gate_activity_id,
279 work_activity_id,
280 WorkflowNode::HostExecutor(node.clone()),
281 ),
282 }
283}
284
285fn decide_plain_node(
286 snapshot: &RunSnapshotDTO,
287 human_gate: Option<&HumanGate>,
288 node_id: &str,
289 gate_activity_id: &str,
290 work_activity_id: &str,
291 node: WorkflowNode,
292) -> AdvanceDecision {
293 if let Some(gate_cfg) = human_gate {
294 let Some(gate) = activity_state(snapshot, gate_activity_id) else {
295 return AdvanceDecision {
296 actions: vec![OrchestratorAction::DispatchGate {
297 node_id: node_id.to_string(),
298 activity_id: gate_activity_id.to_string(),
299 human_gate: gate_cfg.clone(),
300 }],
301 is_succeeded: false,
302 is_failed: false,
303 };
304 };
305 match gate.status {
306 ActivityStatus::Failed | ActivityStatus::TimedOut => {
307 return AdvanceDecision {
308 actions: vec![OrchestratorAction::CompleteNodeFailed {
309 node_id: node_id.to_string(),
310 last_activity_id: gate_activity_id.to_string(),
311 error_class: if gate.status == ActivityStatus::TimedOut {
312 "userFault".to_string()
313 } else {
314 derive_error_class(gate)
315 },
316 }],
317 is_succeeded: false,
318 is_failed: true,
319 };
320 }
321 ActivityStatus::Succeeded => {}
322 _ => {
323 return AdvanceDecision {
324 actions: Vec::new(),
325 is_succeeded: false,
326 is_failed: false,
327 };
328 }
329 }
330 }
331
332 let Some(work) = activity_state(snapshot, work_activity_id) else {
333 return AdvanceDecision {
334 actions: vec![OrchestratorAction::DispatchWork {
335 node_id: node_id.to_string(),
336 activity_id: work_activity_id.to_string(),
337 node,
338 }],
339 is_succeeded: false,
340 is_failed: false,
341 };
342 };
343 match work.status {
344 ActivityStatus::Succeeded => {
345 let output_ref = snapshot.outputs.get(work_activity_id).cloned();
346 AdvanceDecision {
347 actions: vec![OrchestratorAction::CompleteNodeSucceeded {
348 node_id: node_id.to_string(),
349 last_activity_id: work_activity_id.to_string(),
350 output_ref,
351 }],
352 is_succeeded: true,
353 is_failed: false,
354 }
355 }
356 ActivityStatus::Failed | ActivityStatus::TimedOut => AdvanceDecision {
357 actions: vec![OrchestratorAction::CompleteNodeFailed {
358 node_id: node_id.to_string(),
359 last_activity_id: work_activity_id.to_string(),
360 error_class: if work.status == ActivityStatus::TimedOut {
361 "retryable".to_string()
362 } else {
363 derive_error_class(work)
364 },
365 }],
366 is_succeeded: false,
367 is_failed: true,
368 },
369 _ => AdvanceDecision {
370 actions: Vec::new(),
371 is_succeeded: false,
372 is_failed: false,
373 },
374 }
375}
376
377fn extract_wait_resolution_meta(
380 snapshot: &RunSnapshotDTO,
381 gate_activity_id: &str,
382) -> (Option<String>, Option<String>, Option<String>, Option<bool>) {
383 let Some(activity) = snapshot
384 .activities
385 .iter()
386 .find(|a| a.activity_id == gate_activity_id)
387 else {
388 return (None, None, None, None);
389 };
390 let Some(latest) = activity.attempts.last() else {
391 return (None, None, None, None);
392 };
393 let Some(wait) = latest.wait.as_ref() else {
394 return (None, None, None, None);
395 };
396 let Some(resolution) = wait.resolution.as_ref() else {
397 return (None, None, None, None);
398 };
399 let timed_out = match resolution.kind.as_str() {
400 "deadlineExceeded" => Some(true),
401 _ => Some(false),
402 };
403 (
404 resolution.event_id.clone(),
405 resolution.by.clone(),
406 resolution.comment.clone(),
407 timed_out,
408 )
409}
410
411fn decide_loop_advancement(
416 snapshot: &RunSnapshotDTO,
417 def: &WorkflowDefinition,
418 loop_id: &str,
419 loop_node: &LoopNode,
420) -> AdvanceDecision {
421 let run_id = &snapshot.run.run_id;
422 let loop_state = snapshot.loops.as_ref().and_then(|loops| loops.get(loop_id));
423
424 match loop_state {
425 None => {
426 AdvanceDecision {
428 actions: vec![
429 OrchestratorAction::StartLoop {
430 node_id: loop_id.to_string(),
431 max_iterations: loop_node.max_iterations,
432 },
433 OrchestratorAction::StartLoopIteration {
434 node_id: loop_id.to_string(),
435 iteration: 1,
436 },
437 ],
438 is_succeeded: false,
439 is_failed: false,
440 }
441 }
442 Some(ls) => match ls.status {
443 LoopStatus::Running => {
444 let running = ls
446 .iterations
447 .iter()
448 .find(|it| matches!(it.status, LoopIterationStatus::Running));
449 match running {
450 Some(iter) => process_loop_iteration_body(
451 snapshot,
452 def,
453 run_id,
454 loop_id,
455 loop_node,
456 iter.iteration,
457 ),
458 None => {
459 let last_iter = ls.iteration;
464 if last_iter < loop_node.max_iterations {
465 AdvanceDecision {
466 actions: vec![OrchestratorAction::StartLoopIteration {
467 node_id: loop_id.to_string(),
468 iteration: last_iter + 1,
469 }],
470 is_succeeded: false,
471 is_failed: false,
472 }
473 } else {
474 AdvanceDecision {
475 actions: vec![],
476 is_succeeded: false,
477 is_failed: false,
478 }
479 }
480 }
481 }
482 }
483 LoopStatus::Succeeded => AdvanceDecision {
484 actions: vec![],
485 is_succeeded: true,
486 is_failed: false,
487 },
488 LoopStatus::Failed | LoopStatus::Cancelled => AdvanceDecision {
489 actions: vec![],
490 is_succeeded: false,
491 is_failed: true,
492 },
493 },
494 }
495}
496
497fn process_loop_iteration_body(
502 snapshot: &RunSnapshotDTO,
503 def: &WorkflowDefinition,
504 run_id: &str,
505 loop_id: &str,
506 loop_node: &LoopNode,
507 iteration: u64,
508) -> AdvanceDecision {
509 let body_order = body_topological_order(def, &loop_node.body);
510 let terminate_node_id = &loop_node.terminate.node;
511
512 for node_id in &body_order {
513 let Some(node) = def.nodes.get(node_id) else {
514 continue;
515 };
516
517 let deps_ok = node_depends(node).iter().all(|dep| {
519 if loop_node.body.contains(dep) {
520 let work_id = loop_work_activity_id(run_id, loop_id, iteration, dep);
522 activity_state(snapshot, &work_id)
523 .map(|a| a.status == ActivityStatus::Succeeded)
524 .unwrap_or(false)
525 } else {
526 dependency_is_succeeded(snapshot, dep)
528 }
529 });
530 if !deps_ok {
531 return AdvanceDecision {
532 actions: vec![],
533 is_succeeded: false,
534 is_failed: false,
535 };
536 }
537
538 let is_terminate = node_id == terminate_node_id;
539
540 if is_terminate {
541 match node {
543 WorkflowNode::Decision(decision_node) => {
544 let gate_cfg = match decision_node.base.human_gate.as_ref() {
545 Some(cfg) => cfg,
546 None => {
547 continue;
550 }
551 };
552 let gate_id = loop_gate_activity_id(run_id, loop_id, iteration, node_id);
553 let Some(gate) = activity_state(snapshot, &gate_id) else {
554 return AdvanceDecision {
555 actions: vec![OrchestratorAction::DispatchGate {
556 node_id: node_id.clone(),
557 activity_id: gate_id,
558 human_gate: gate_cfg.clone(),
559 }],
560 is_succeeded: false,
561 is_failed: false,
562 };
563 };
564 match gate.status {
565 ActivityStatus::Succeeded => {
566 let loop_output = loop_node.output.as_ref().and_then(|out| {
568 let source_work_id =
569 loop_work_activity_id(run_id, loop_id, iteration, &out.from);
570 snapshot.outputs.get(&source_work_id).cloned()
571 });
572 let (wait_resolved_event_id, by, comment, timed_out) =
573 extract_wait_resolution_meta(snapshot, &gate_id);
574 return AdvanceDecision {
575 actions: vec![
576 OrchestratorAction::FinishLoopIteration {
577 node_id: loop_id.to_string(),
578 iteration,
579 resolution: "approved".to_string(),
580 decision_activity_id: Some(gate_id),
581 wait_resolved_event_id,
582 by,
583 comment,
584 timed_out,
585 },
586 OrchestratorAction::FinishLoop {
587 node_id: loop_id.to_string(),
588 final_iteration: iteration,
589 resolution: "approved".to_string(),
590 output_ref: loop_output,
591 error_code: None,
592 error_class: None,
593 },
594 ],
595 is_succeeded: false,
596 is_failed: false,
597 };
598 }
599 ActivityStatus::Failed | ActivityStatus::TimedOut => {
600 if iteration >= loop_node.max_iterations {
602 let (wait_resolved_event_id, by, comment, timed_out) =
604 extract_wait_resolution_meta(snapshot, &gate_id);
605 return AdvanceDecision {
606 actions: vec![
607 OrchestratorAction::FinishLoopIteration {
608 node_id: loop_id.to_string(),
609 iteration,
610 resolution: "rejected".to_string(),
611 decision_activity_id: Some(gate_id),
612 wait_resolved_event_id,
613 by,
614 comment,
615 timed_out,
616 },
617 OrchestratorAction::FinishLoop {
618 node_id: loop_id.to_string(),
619 final_iteration: iteration,
620 resolution: "failed".to_string(),
621 output_ref: None,
622 error_code: Some("MaxIterationsReached".to_string()),
623 error_class: Some("fatal".to_string()),
624 },
625 ],
626 is_succeeded: false,
627 is_failed: false,
628 };
629 }
630 let (wait_resolved_event_id, by, comment, timed_out) =
632 extract_wait_resolution_meta(snapshot, &gate_id);
633 return AdvanceDecision {
634 actions: vec![
635 OrchestratorAction::FinishLoopIteration {
636 node_id: loop_id.to_string(),
637 iteration,
638 resolution: "rejected".to_string(),
639 decision_activity_id: Some(gate_id),
640 wait_resolved_event_id,
641 by,
642 comment,
643 timed_out,
644 },
645 OrchestratorAction::StartLoopIteration {
646 node_id: loop_id.to_string(),
647 iteration: iteration + 1,
648 },
649 ],
650 is_succeeded: false,
651 is_failed: false,
652 };
653 }
654 _ => {
655 return AdvanceDecision {
657 actions: vec![],
658 is_succeeded: false,
659 is_failed: false,
660 };
661 }
662 }
663 }
664 _ => {
665 continue;
668 }
669 }
670 } else {
671 if let Some(human_gate) = node_human_gate(node) {
675 let gate_id = loop_gate_activity_id(run_id, loop_id, iteration, node_id);
676 let Some(gate) = activity_state(snapshot, &gate_id) else {
677 return AdvanceDecision {
678 actions: vec![OrchestratorAction::DispatchGate {
679 node_id: node_id.clone(),
680 activity_id: gate_id,
681 human_gate: human_gate.clone(),
682 }],
683 is_succeeded: false,
684 is_failed: false,
685 };
686 };
687 match gate.status {
688 ActivityStatus::Failed | ActivityStatus::TimedOut => {
689 return AdvanceDecision {
691 actions: vec![
692 OrchestratorAction::FinishLoopIteration {
693 node_id: loop_id.to_string(),
694 iteration,
695 resolution: "failed".to_string(),
696 decision_activity_id: None,
697 wait_resolved_event_id: None,
698 by: None,
699 comment: None,
700 timed_out: None,
701 },
702 OrchestratorAction::FinishLoop {
703 node_id: loop_id.to_string(),
704 final_iteration: iteration,
705 resolution: "failed".to_string(),
706 output_ref: None,
707 error_code: Some("BodyNodeGateFailed".to_string()),
708 error_class: Some("fatal".to_string()),
709 },
710 ],
711 is_succeeded: false,
712 is_failed: false,
713 };
714 }
715 ActivityStatus::Succeeded => {
716 }
718 _ => {
719 return AdvanceDecision {
720 actions: vec![],
721 is_succeeded: false,
722 is_failed: false,
723 };
724 }
725 }
726 }
727
728 let work_id = loop_work_activity_id(run_id, loop_id, iteration, node_id);
730 let Some(work) = activity_state(snapshot, &work_id) else {
731 return AdvanceDecision {
732 actions: vec![OrchestratorAction::DispatchWork {
733 node_id: node_id.clone(),
734 activity_id: work_id,
735 node: node.clone(),
736 }],
737 is_succeeded: false,
738 is_failed: false,
739 };
740 };
741
742 match work.status {
743 ActivityStatus::Succeeded => {
744 continue;
746 }
747 ActivityStatus::Failed | ActivityStatus::TimedOut => {
748 return AdvanceDecision {
750 actions: vec![
751 OrchestratorAction::FinishLoopIteration {
752 node_id: loop_id.to_string(),
753 iteration,
754 resolution: "failed".to_string(),
755 decision_activity_id: None,
756 wait_resolved_event_id: None,
757 by: None,
758 comment: None,
759 timed_out: None,
760 },
761 OrchestratorAction::FinishLoop {
762 node_id: loop_id.to_string(),
763 final_iteration: iteration,
764 resolution: "failed".to_string(),
765 output_ref: None,
766 error_code: Some("BodyNodeFailed".to_string()),
767 error_class: Some("fatal".to_string()),
768 },
769 ],
770 is_succeeded: false,
771 is_failed: false,
772 };
773 }
774 _ => {
775 return AdvanceDecision {
777 actions: vec![],
778 is_succeeded: false,
779 is_failed: false,
780 };
781 }
782 }
783 }
784 }
785
786 AdvanceDecision {
788 actions: vec![],
789 is_succeeded: false,
790 is_failed: false,
791 }
792}
793
794pub fn topological_order(def: &WorkflowDefinition) -> Vec<String> {
795 let mut indegree: BTreeMap<String, usize> = def
796 .nodes
797 .keys()
798 .map(|node_id| (node_id.clone(), 0))
799 .collect();
800 let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
801 for (node_id, node) in &def.nodes {
802 for dep in node_depends(node) {
803 *indegree.entry(node_id.clone()).or_insert(0) += 1;
804 outgoing
805 .entry(dep.clone())
806 .or_default()
807 .push(node_id.clone());
808 }
809 }
810
811 let mut ready: BTreeSet<String> = indegree
812 .iter()
813 .filter_map(|(node_id, degree)| (*degree == 0).then_some(node_id.clone()))
814 .collect();
815 let mut order = Vec::with_capacity(def.nodes.len());
816 while let Some(node_id) = ready.iter().next().cloned() {
817 ready.take(&node_id);
818 order.push(node_id.clone());
819 if let Some(children) = outgoing.get(&node_id) {
820 for child in children {
821 if let Some(entry) = indegree.get_mut(child) {
822 *entry = entry.saturating_sub(1);
823 if *entry == 0 {
824 ready.insert(child.clone());
825 }
826 }
827 }
828 }
829 }
830 if order.len() != def.nodes.len() {
831 return def.nodes.keys().cloned().collect();
832 }
833 order
834}
835
836fn build_body_owner_map(def: &WorkflowDefinition) -> HashMap<String, String> {
837 let mut owner = HashMap::new();
838 for (loop_id, node) in &def.nodes {
839 if let WorkflowNode::Loop(loop_node) = node {
840 for body_id in &loop_node.body {
841 owner.insert(body_id.clone(), loop_id.clone());
842 }
843 }
844 }
845 owner
846}
847
848fn find_sinks(def: &WorkflowDefinition) -> Vec<String> {
849 let body_owner = build_body_owner_map(def);
850 let mut referenced = BTreeSet::new();
851 for (node_id, node) in &def.nodes {
852 if body_owner.contains_key(node_id) {
853 continue;
854 }
855 for dep in node_depends(node) {
856 referenced.insert(dep.clone());
857 }
858 }
859 def.nodes
860 .iter()
861 .filter_map(|(node_id, node)| {
862 if body_owner.contains_key(node_id) {
863 return None;
864 }
865 if matches!(node, WorkflowNode::Decision(_)) {
866 return None;
867 }
868 (!referenced.contains(node_id)).then_some(node_id.clone())
869 })
870 .collect()
871}
872
873fn dependency_is_succeeded(snapshot: &RunSnapshotDTO, node_id: &str) -> bool {
874 if let Some(node) = node_state(snapshot, node_id) {
875 if node.status == NodeStatus::Succeeded {
876 return true;
877 }
878 }
879 if let Some(loop_state) = snapshot.loops.as_ref().and_then(|loops| loops.get(node_id)) {
880 return matches!(loop_state.status, LoopStatus::Succeeded);
881 }
882 false
883}
884
885fn node_state<'a>(snapshot: &'a RunSnapshotDTO, node_id: &str) -> Option<&'a NodeState> {
886 snapshot.nodes.iter().find(|node| node.node_id == node_id)
887}
888
889fn activity_state<'a>(
890 snapshot: &'a RunSnapshotDTO,
891 activity_id: &str,
892) -> Option<&'a ActivityState> {
893 snapshot
894 .activities
895 .iter()
896 .find(|activity| activity.activity_id == activity_id)
897}
898
899fn node_depends(node: &WorkflowNode) -> &[String] {
900 match node {
901 WorkflowNode::Subagent(node) => node.base.depends.as_deref().unwrap_or(&[]),
902 WorkflowNode::HostExecutor(node) => node.base.depends.as_deref().unwrap_or(&[]),
903 WorkflowNode::Loop(node) => node.base.depends.as_deref().unwrap_or(&[]),
904 WorkflowNode::Decision(node) => node.base.depends.as_deref().unwrap_or(&[]),
905 }
906}
907
908fn derive_error_class(activity: &ActivityState) -> String {
909 let Some(last) = activity.attempts.last() else {
910 return "fatal".to_string();
911 };
912 last.error
913 .as_ref()
914 .and_then(|value| value.get("errorClass"))
915 .and_then(Value::as_str)
916 .unwrap_or("fatal")
917 .to_string()
918}
919
920fn gate_activity_id(run_id: &str, node_id: &str) -> String {
921 format!("{run_id}::gate::{node_id}")
922}
923
924fn work_activity_id(run_id: &str, node_id: &str) -> String {
925 format!("{run_id}::work::{node_id}")
926}
927
928fn loop_gate_activity_id(run_id: &str, loop_id: &str, iteration: u64, node_id: &str) -> String {
931 format!("{run_id}::loop::{loop_id}.{iteration}::gate::{node_id}")
932}
933
934fn loop_work_activity_id(run_id: &str, loop_id: &str, iteration: u64, node_id: &str) -> String {
937 format!("{run_id}::loop::{loop_id}.{iteration}::work::{node_id}")
938}
939
940fn node_human_gate(node: &WorkflowNode) -> Option<&HumanGate> {
942 match node {
943 WorkflowNode::Subagent(n) => n.base.human_gate.as_ref(),
944 WorkflowNode::HostExecutor(n) => n.base.human_gate.as_ref(),
945 WorkflowNode::Loop(n) => n.base.human_gate.as_ref(),
946 WorkflowNode::Decision(n) => n.base.human_gate.as_ref(),
947 }
948}
949
950fn body_topological_order(def: &WorkflowDefinition, body_nodes: &[String]) -> Vec<String> {
953 let body_set: BTreeSet<&String> = body_nodes.iter().collect();
954 let mut indegree: BTreeMap<&String, usize> = body_nodes.iter().map(|id| (id, 0)).collect();
955 let mut outgoing: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
956
957 for node_id in body_nodes {
958 let Some(node) = def.nodes.get(node_id) else {
959 continue;
960 };
961 for dep in node_depends(node) {
962 if body_set.contains(dep) {
963 *indegree.get_mut(node_id).unwrap() += 1;
964 outgoing.entry(dep).or_default().push(node_id);
965 }
966 }
967 }
968
969 let mut ready: BTreeSet<&String> = indegree
971 .iter()
972 .filter(|(_, d)| **d == 0)
973 .map(|(id, _)| *id)
974 .collect();
975 let mut order = Vec::with_capacity(body_nodes.len());
976
977 while let Some(id) = ready.iter().next().cloned() {
978 ready.take(id);
979 order.push(id.clone());
980 if let Some(children) = outgoing.get(id) {
981 for child in children {
982 if let Some(entry) = indegree.get_mut(child) {
983 *entry = entry.saturating_sub(1);
984 if *entry == 0 {
985 ready.insert(child);
986 }
987 }
988 }
989 }
990 }
991
992 if order.len() != body_nodes.len() {
993 return body_nodes.to_vec();
995 }
996 order
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use crate::workflow_definition::NodeBase;
1003 use crate::workflow_snapshot::{ActivityStatus, NodeStatus};
1004 use crate::{ActivityState, NodeState, RunChatBinding, RunState, RunStatus, WorkflowOutputRef};
1005 use crate::{LoopIterationState, LoopSnapshotDTO};
1006
1007 fn output_ref(name: &str) -> WorkflowOutputRef {
1008 WorkflowOutputRef {
1009 output_hash: format!("sha256:{name}"),
1010 output_path: format!("/tmp/{name}.json"),
1011 output_bytes: 4,
1012 output_schema_version: 1,
1013 content_type: Some("application/json".to_string()),
1014 }
1015 }
1016
1017 fn snapshot() -> RunSnapshotDTO {
1018 RunSnapshotDTO {
1019 run_id: "run-1".to_string(),
1020 run: RunState {
1021 run_id: "run-1".to_string(),
1022 status: RunStatus::Running,
1023 workflow_id: Some("flow-a".to_string()),
1024 revision_id: Some("rev-a".to_string()),
1025 initiator: Some("cli".to_string()),
1026 input: None,
1027 output: None,
1028 failed_node_id: None,
1029 root_cause_event_id: None,
1030 cancel_origin_event_id: None,
1031 bot_snapshots: None,
1032 cancelled_run_intent: None,
1033 cancelled_node_intents: BTreeMap::new(),
1034 },
1035 last_seq: 2,
1036 nodes: Vec::new(),
1037 activities: Vec::new(),
1038 loops: None,
1039 dangling: crate::DanglingSnapshot {
1040 activities: Vec::new(),
1041 effect_attempted: Vec::new(),
1042 waits: Vec::new(),
1043 wait_resolutions: Vec::new(),
1044 cancels: Vec::new(),
1045 },
1046 outputs: BTreeMap::new(),
1047 attempt_io: BTreeMap::new(),
1048 chat_binding: Some(RunChatBinding {
1049 chat_id: "chat-1".to_string(),
1050 lark_app_id: "app-1".to_string(),
1051 }),
1052 updated_at: 1,
1053 }
1054 }
1055
1056 fn subagent_node(depends: &[&str]) -> WorkflowNode {
1057 WorkflowNode::Subagent(crate::SubagentNode {
1058 base: NodeBase {
1059 description: None,
1060 depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1061 human_gate: None,
1062 retry_policy: None,
1063 timeout_ms: None,
1064 max_output_bytes: None,
1065 output_schema: None,
1066 unsafe_allow_ungated: None,
1067 },
1068 bot: "bot-a".to_string(),
1069 prompt: Value::String("hi".to_string()),
1070 working_dir: None,
1071 model_overrides: None,
1072 tool_policy: None,
1073 })
1074 }
1075
1076 fn host_node(depends: &[&str]) -> WorkflowNode {
1077 WorkflowNode::HostExecutor(crate::HostExecutorNode {
1078 base: NodeBase {
1079 description: None,
1080 depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1081 human_gate: None,
1082 retry_policy: None,
1083 timeout_ms: None,
1084 max_output_bytes: None,
1085 output_schema: None,
1086 unsafe_allow_ungated: None,
1087 },
1088 executor: "feishu-send".to_string(),
1089 input: Value::Null,
1090 })
1091 }
1092
1093 fn gate_node(depends: &[&str]) -> WorkflowNode {
1094 WorkflowNode::Subagent(crate::SubagentNode {
1095 base: NodeBase {
1096 description: None,
1097 depends: Some(depends.iter().map(|s| s.to_string()).collect()),
1098 human_gate: Some(HumanGate {
1099 stage: "before".to_string(),
1100 prompt: Value::String("approve?".to_string()),
1101 approvers: None,
1102 deadline_ms: None,
1103 on_timeout: None,
1104 }),
1105 retry_policy: None,
1106 timeout_ms: None,
1107 max_output_bytes: None,
1108 output_schema: None,
1109 unsafe_allow_ungated: None,
1110 },
1111 bot: "bot-a".to_string(),
1112 prompt: Value::String("hi".to_string()),
1113 working_dir: None,
1114 model_overrides: None,
1115 tool_policy: None,
1116 })
1117 }
1118
1119 #[test]
1120 fn decide_next_actions_dispatches_root_work_then_downstream() {
1121 let def = WorkflowDefinition {
1122 workflow_id: "flow-a".to_string(),
1123 version: 1,
1124 params: None,
1125 defaults: None,
1126 nodes: BTreeMap::from([
1127 ("a".to_string(), subagent_node(&[])),
1128 ("b".to_string(), host_node(&["a"])),
1129 ]),
1130 };
1131 let mut snap = snapshot();
1132 let actions = decide_next_actions(&snap, &def);
1133 assert!(matches!(
1134 actions.as_slice(),
1135 [OrchestratorAction::DispatchWork { node_id, activity_id, .. }]
1136 if node_id == "a" && activity_id == "run-1::work::a"
1137 ));
1138
1139 snap.nodes = vec![NodeState {
1140 node_id: "a".to_string(),
1141 status: NodeStatus::Succeeded,
1142 activity_id: Some("run-1::work::a".to_string()),
1143 retry_count: 0,
1144 next_attempt_at: None,
1145 error_class: None,
1146 condition_event_id: None,
1147 cancel_origin_event_id: None,
1148 }];
1149 snap.activities = vec![ActivityState {
1150 activity_id: "run-1::work::a".to_string(),
1151 attempts: vec![],
1152 status: ActivityStatus::Succeeded,
1153 current_attempt_id: None,
1154 owner_node_id: Some("a".to_string()),
1155 }];
1156 snap.outputs
1157 .insert("run-1::work::a".to_string(), output_ref("a"));
1158 let actions = decide_next_actions(&snap, &def);
1159 assert!(matches!(
1160 actions.as_slice(),
1161 [OrchestratorAction::DispatchWork { node_id, activity_id, .. }]
1162 if node_id == "b" && activity_id == "run-1::work::b"
1163 ));
1164 }
1165
1166 #[test]
1167 fn decide_next_actions_completes_simple_run_when_single_sink_has_output() {
1168 let def = WorkflowDefinition {
1169 workflow_id: "flow-a".to_string(),
1170 version: 1,
1171 params: None,
1172 defaults: None,
1173 nodes: BTreeMap::from([
1174 ("a".to_string(), subagent_node(&[])),
1175 ("b".to_string(), host_node(&["a"])),
1176 ]),
1177 };
1178 let mut snap = snapshot();
1179 snap.nodes = vec![
1180 NodeState {
1181 node_id: "a".to_string(),
1182 status: NodeStatus::Succeeded,
1183 activity_id: Some("run-1::work::a".to_string()),
1184 retry_count: 0,
1185 next_attempt_at: None,
1186 error_class: None,
1187 condition_event_id: None,
1188 cancel_origin_event_id: None,
1189 },
1190 NodeState {
1191 node_id: "b".to_string(),
1192 status: NodeStatus::Succeeded,
1193 activity_id: Some("run-1::work::b".to_string()),
1194 retry_count: 0,
1195 next_attempt_at: None,
1196 error_class: None,
1197 condition_event_id: None,
1198 cancel_origin_event_id: None,
1199 },
1200 ];
1201 snap.activities = vec![ActivityState {
1202 activity_id: "run-1::work::b".to_string(),
1203 attempts: vec![],
1204 status: ActivityStatus::Succeeded,
1205 current_attempt_id: None,
1206 owner_node_id: Some("b".to_string()),
1207 }];
1208 snap.outputs
1209 .insert("run-1::work::b".to_string(), output_ref("b"));
1210 let actions = decide_next_actions(&snap, &def);
1211 assert!(matches!(
1212 actions.as_slice(),
1213 [OrchestratorAction::CompleteRunSucceeded { sink_node_id, .. }]
1214 if sink_node_id == "b"
1215 ));
1216 }
1217
1218 #[test]
1219 fn decide_next_actions_reports_node_failure_before_run_failure() {
1220 let def = WorkflowDefinition {
1221 workflow_id: "flow-a".to_string(),
1222 version: 1,
1223 params: None,
1224 defaults: None,
1225 nodes: BTreeMap::from([
1226 ("a".to_string(), subagent_node(&[])),
1227 ("b".to_string(), host_node(&["a"])),
1228 ]),
1229 };
1230 let mut snap = snapshot();
1231 snap.nodes = vec![NodeState {
1232 node_id: "a".to_string(),
1233 status: NodeStatus::Failed,
1234 activity_id: Some("run-1::work::a".to_string()),
1235 retry_count: 0,
1236 next_attempt_at: None,
1237 error_class: Some("fatal".to_string()),
1238 condition_event_id: None,
1239 cancel_origin_event_id: None,
1240 }];
1241 let actions = decide_next_actions(&snap, &def);
1242 assert!(matches!(
1243 actions.as_slice(),
1244 [OrchestratorAction::CompleteRunFailed { failed_node_id }]
1245 if failed_node_id == "a"
1246 ));
1247 }
1248
1249 #[test]
1250 fn decide_next_actions_dispatches_gate_before_work() {
1251 let def = WorkflowDefinition {
1252 workflow_id: "flow-a".to_string(),
1253 version: 1,
1254 params: None,
1255 defaults: None,
1256 nodes: BTreeMap::from([("a".to_string(), gate_node(&[]))]),
1257 };
1258 let snap = snapshot();
1259 let actions = decide_next_actions(&snap, &def);
1260 assert!(matches!(
1261 actions.as_slice(),
1262 [OrchestratorAction::DispatchGate { node_id, activity_id, .. }]
1263 if node_id == "a" && activity_id == "run-1::gate::a"
1264 ));
1265 }
1266
1267 fn decision_node(human_gate: HumanGate) -> WorkflowNode {
1270 WorkflowNode::Decision(crate::DecisionNode {
1271 base: NodeBase {
1272 description: None,
1273 depends: None,
1274 human_gate: Some(human_gate),
1275 retry_policy: None,
1276 timeout_ms: None,
1277 max_output_bytes: None,
1278 output_schema: None,
1279 unsafe_allow_ungated: None,
1280 },
1281 })
1282 }
1283
1284 fn loop_node_with_body(body: &[&str], max_iterations: u64) -> WorkflowNode {
1285 WorkflowNode::Loop(LoopNode {
1286 base: NodeBase {
1287 description: None,
1288 depends: None,
1289 human_gate: None,
1290 retry_policy: None,
1291 timeout_ms: None,
1292 max_output_bytes: None,
1293 output_schema: None,
1294 unsafe_allow_ungated: None,
1295 },
1296 max_iterations,
1297 body: body.iter().map(|s| s.to_string()).collect(),
1298 terminate: crate::LoopTerminate {
1299 node: body.last().unwrap_or(&"").to_string(),
1300 via: "humanGate".to_string(),
1301 },
1302 output: None,
1303 })
1304 }
1305
1306 impl Default for HumanGate {
1307 fn default() -> Self {
1308 HumanGate {
1309 stage: "before".to_string(),
1310 prompt: Value::String("approve?".to_string()),
1311 approvers: None,
1312 deadline_ms: None,
1313 on_timeout: None,
1314 }
1315 }
1316 }
1317
1318 #[test]
1319 fn loop_start_when_deps_met_and_no_loop_state() {
1320 let def = WorkflowDefinition {
1321 workflow_id: "flow-a".to_string(),
1322 version: 1,
1323 params: None,
1324 defaults: None,
1325 nodes: BTreeMap::from([
1326 ("d".to_string(), decision_node(HumanGate::default())),
1327 ("l".to_string(), loop_node_with_body(&["d"], 3)),
1328 ]),
1329 };
1330 let snap = snapshot();
1331 let actions = decide_next_actions(&snap, &def);
1332 assert!(
1333 actions.len() >= 2,
1334 "expected StartLoop and StartLoopIteration, got {:?}",
1335 actions
1336 );
1337 assert!(
1338 actions
1339 .iter()
1340 .any(|a| matches!(a, OrchestratorAction::StartLoop { .. }))
1341 );
1342 assert!(actions.iter().any(|a| matches!(
1343 a,
1344 OrchestratorAction::StartLoopIteration { iteration: 1, .. }
1345 )));
1346 }
1347
1348 #[test]
1349 fn loop_with_succeeded_decision_gate_finishes_loop() {
1350 let def = WorkflowDefinition {
1355 workflow_id: "flow-a".to_string(),
1356 version: 1,
1357 params: None,
1358 defaults: None,
1359 nodes: BTreeMap::from([
1360 ("d".to_string(), decision_node(HumanGate::default())),
1361 ("l".to_string(), loop_node_with_body(&["d"], 3)),
1362 ]),
1363 };
1364
1365 let mut snap = snapshot();
1366 let run_id = &snap.run.run_id;
1367 let decision_gate_id = format!("{run_id}::loop::l.1::gate::d");
1368
1369 snap.loops = Some(BTreeMap::from([(
1371 "l".to_string(),
1372 LoopSnapshotDTO {
1373 loop_id: "l".to_string(),
1374 status: LoopStatus::Running,
1375 iteration: 1,
1376 max_iterations: 3,
1377 iterations: vec![LoopIterationState {
1378 iteration: 1,
1379 status: LoopIterationStatus::Running,
1380 body_activity_ids: vec![decision_gate_id.clone()],
1381 decision_activity_id: None,
1382 wait_resolved_event_id: None,
1383 decision_by: None,
1384 decision_comment: None,
1385 timed_out: None,
1386 }],
1387 output: None,
1388 error_code: None,
1389 error_class: None,
1390 },
1391 )]));
1392
1393 snap.activities = vec![ActivityState {
1395 activity_id: decision_gate_id.clone(),
1396 attempts: vec![],
1397 status: ActivityStatus::Succeeded,
1398 current_attempt_id: None,
1399 owner_node_id: Some("d".to_string()),
1400 }];
1401
1402 let actions = decide_next_actions(&snap, &def);
1403 assert!(
1404 actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoopIteration { resolution, .. } if resolution == "approved")),
1405 "expected FinishLoopIteration(approved), got: {actions:?}"
1406 );
1407 assert!(
1408 actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { resolution, .. } if resolution == "approved")),
1409 "expected FinishLoop(approved), got: {actions:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn loop_with_failed_decision_gate_max_iter_reached_fails_loop() {
1415 let def = WorkflowDefinition {
1416 workflow_id: "flow-a".to_string(),
1417 version: 1,
1418 params: None,
1419 defaults: None,
1420 nodes: BTreeMap::from([
1421 ("d".to_string(), decision_node(HumanGate::default())),
1422 ("l".to_string(), loop_node_with_body(&["d"], 2)),
1423 ]),
1424 };
1425
1426 let mut snap = snapshot();
1427 let run_id = &snap.run.run_id;
1428 let decision_gate_id = format!("{run_id}::loop::l.2::gate::d");
1429
1430 snap.loops = Some(BTreeMap::from([(
1432 "l".to_string(),
1433 LoopSnapshotDTO {
1434 loop_id: "l".to_string(),
1435 status: LoopStatus::Running,
1436 iteration: 2,
1437 max_iterations: 2,
1438 iterations: vec![LoopIterationState {
1439 iteration: 2,
1440 status: LoopIterationStatus::Running,
1441 body_activity_ids: vec![decision_gate_id.clone()],
1442 decision_activity_id: None,
1443 wait_resolved_event_id: None,
1444 decision_by: None,
1445 decision_comment: None,
1446 timed_out: None,
1447 }],
1448 output: None,
1449 error_code: None,
1450 error_class: None,
1451 },
1452 )]));
1453
1454 snap.activities = vec![ActivityState {
1455 activity_id: decision_gate_id,
1456 attempts: vec![],
1457 status: ActivityStatus::Failed,
1458 current_attempt_id: None,
1459 owner_node_id: Some("d".to_string()),
1460 }];
1461
1462 let actions = decide_next_actions(&snap, &def);
1463 assert!(
1464 actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { resolution, .. } if resolution == "failed")),
1465 "expected FinishLoop(failed), got: {actions:?}"
1466 );
1467 assert!(
1468 actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoop { error_code, .. } if error_code.as_deref() == Some("MaxIterationsReached"))),
1469 "expected MaxIterationsReached, got: {actions:?}"
1470 );
1471 }
1472
1473 #[test]
1474 fn loop_with_rejected_decision_below_max_starts_next_iteration() {
1475 let def = WorkflowDefinition {
1476 workflow_id: "flow-a".to_string(),
1477 version: 1,
1478 params: None,
1479 defaults: None,
1480 nodes: BTreeMap::from([
1481 ("d".to_string(), decision_node(HumanGate::default())),
1482 ("l".to_string(), loop_node_with_body(&["d"], 3)),
1483 ]),
1484 };
1485
1486 let mut snap = snapshot();
1487 let run_id = &snap.run.run_id;
1488 let decision_gate_id = format!("{run_id}::loop::l.1::gate::d");
1489
1490 snap.loops = Some(BTreeMap::from([(
1491 "l".to_string(),
1492 LoopSnapshotDTO {
1493 loop_id: "l".to_string(),
1494 status: LoopStatus::Running,
1495 iteration: 1,
1496 max_iterations: 3,
1497 iterations: vec![LoopIterationState {
1498 iteration: 1,
1499 status: LoopIterationStatus::Running,
1500 body_activity_ids: vec![decision_gate_id.clone()],
1501 decision_activity_id: None,
1502 wait_resolved_event_id: None,
1503 decision_by: None,
1504 decision_comment: None,
1505 timed_out: None,
1506 }],
1507 output: None,
1508 error_code: None,
1509 error_class: None,
1510 },
1511 )]));
1512
1513 snap.activities = vec![ActivityState {
1514 activity_id: decision_gate_id,
1515 attempts: vec![],
1516 status: ActivityStatus::Failed,
1517 current_attempt_id: None,
1518 owner_node_id: Some("d".to_string()),
1519 }];
1520
1521 let actions = decide_next_actions(&snap, &def);
1522 assert!(
1523 actions.iter().any(|a| matches!(a, OrchestratorAction::FinishLoopIteration { resolution, .. } if resolution == "rejected")),
1524 "expected FinishLoopIteration(rejected), got: {actions:?}"
1525 );
1526 assert!(
1527 actions.iter().any(|a| matches!(
1528 a,
1529 OrchestratorAction::StartLoopIteration { iteration: 2, .. }
1530 )),
1531 "expected StartLoopIteration(2), got: {actions:?}"
1532 );
1533 }
1534}