1use std::fmt;
20use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
21use std::sync::{Arc, Mutex};
22use std::time::Instant;
23
24const ENV_PREFIX: &str = "IRONFLOW_GUARD_";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct WorkflowGuardConfig {
48 pub max_depth: u32,
50 pub max_fan_out: u32,
52 pub max_workflow_tokens: u64,
54 pub workflow_timeout_secs: u64,
56}
57
58impl Default for WorkflowGuardConfig {
59 fn default() -> Self {
60 Self {
61 max_depth: 5,
62 max_fan_out: 20,
63 max_workflow_tokens: 100_000,
64 workflow_timeout_secs: 120,
65 }
66 }
67}
68
69impl WorkflowGuardConfig {
70 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn from_env() -> Self {
99 Self {
100 max_depth: parse_env("MAX_DEPTH", 5),
101 max_fan_out: parse_env("MAX_FAN_OUT", 20),
102 max_workflow_tokens: parse_env("MAX_WORKFLOW_TOKENS", 100_000),
103 workflow_timeout_secs: parse_env("WORKFLOW_TIMEOUT_SECS", 120),
104 }
105 }
106
107 pub fn with_max_depth(mut self, max_depth: u32) -> Self {
118 self.max_depth = max_depth;
119 self
120 }
121
122 pub fn with_max_fan_out(mut self, max_fan_out: u32) -> Self {
133 self.max_fan_out = max_fan_out;
134 self
135 }
136
137 pub fn with_max_workflow_tokens(mut self, max: u64) -> Self {
148 self.max_workflow_tokens = max;
149 self
150 }
151
152 pub fn with_workflow_timeout_secs(mut self, secs: u64) -> Self {
163 self.workflow_timeout_secs = secs;
164 self
165 }
166}
167
168fn parse_env<T: std::str::FromStr>(suffix: &str, default: T) -> T {
169 std::env::var(format!("{ENV_PREFIX}{suffix}"))
170 .ok()
171 .and_then(|v| v.parse().ok())
172 .unwrap_or(default)
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum WorkflowRejection {
191 MaxDepthExceeded {
193 depth: u32,
195 max: u32,
197 },
198 CycleDetected {
200 target: String,
202 chain: Vec<String>,
204 },
205 MaxFanOutExceeded {
207 invocations: u32,
209 max: u32,
211 },
212 TokenBudgetExhausted {
214 used: u64,
216 max: u64,
218 },
219 WorkflowTimeout {
221 elapsed_secs: u64,
223 max: u64,
225 },
226 GuardUnavailable,
228}
229
230pub const WORKFLOW_GUARD_REJECTED_CODE: &str = "WORKFLOW_GUARD_REJECTED";
232
233impl fmt::Display for WorkflowRejection {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 match self {
236 Self::MaxDepthExceeded { depth, max } => {
237 write!(f, "max call depth exceeded: {depth}/{max}")
238 }
239 Self::CycleDetected { target, chain } => {
240 write!(
241 f,
242 "cycle detected: workflow {target:?} already in chain {chain:?}"
243 )
244 }
245 Self::MaxFanOutExceeded { invocations, max } => {
246 write!(f, "max fan-out exceeded: {invocations}/{max} invocations")
247 }
248 Self::TokenBudgetExhausted { used, max } => {
249 write!(f, "token budget exhausted: {used}/{max}")
250 }
251 Self::WorkflowTimeout { elapsed_secs, max } => {
252 write!(f, "workflow timeout: {elapsed_secs}s/{max}s")
253 }
254 Self::GuardUnavailable => {
255 write!(f, "workflow guard unavailable -- failing closed")
256 }
257 }
258 }
259}
260
261impl std::error::Error for WorkflowRejection {}
262
263#[derive(Debug)]
281pub struct WorkflowGuardState {
282 depth: u32,
284 call_chain: Vec<String>,
286 totals: Arc<GuardTotals>,
288 started_at: Instant,
290}
291
292#[derive(Debug, Default)]
295struct GuardTotals {
296 invocations: AtomicU32,
298 tokens_used: AtomicU64,
300}
301
302impl WorkflowGuardState {
303 pub fn new() -> Self {
315 Self {
316 depth: 0,
317 call_chain: Vec::new(),
318 totals: Arc::new(GuardTotals::default()),
319 started_at: Instant::now(),
320 }
321 }
322
323 pub(crate) fn branch_into(&self, target_workflow: &str) -> Self {
332 let mut call_chain = self.call_chain.clone();
333 call_chain.push(target_workflow.to_string());
334 Self {
335 depth: self.depth + 1,
336 call_chain,
337 totals: self.totals.clone(),
338 started_at: self.started_at,
339 }
340 }
341
342 pub fn depth(&self) -> u32 {
344 self.depth
345 }
346
347 pub fn total_invocations(&self) -> u32 {
349 self.totals.invocations.load(Ordering::Relaxed)
350 }
351
352 pub fn total_tokens_used(&self) -> u64 {
354 self.totals.tokens_used.load(Ordering::Relaxed)
355 }
356
357 pub fn call_chain(&self) -> &[String] {
359 &self.call_chain
360 }
361
362 pub fn elapsed_secs(&self) -> u64 {
364 self.started_at.elapsed().as_secs()
365 }
366
367 pub fn check(
389 &self,
390 config: &WorkflowGuardConfig,
391 target_workflow: &str,
392 ) -> Result<(), WorkflowRejection> {
393 if self.depth >= config.max_depth {
394 return Err(WorkflowRejection::MaxDepthExceeded {
395 depth: self.depth,
396 max: config.max_depth,
397 });
398 }
399
400 if self.call_chain.iter().any(|id| id == target_workflow) {
401 return Err(WorkflowRejection::CycleDetected {
402 target: target_workflow.to_string(),
403 chain: self.call_chain.clone(),
404 });
405 }
406
407 let invocations = self.total_invocations();
408 if invocations >= config.max_fan_out {
409 return Err(WorkflowRejection::MaxFanOutExceeded {
410 invocations,
411 max: config.max_fan_out,
412 });
413 }
414
415 let used = self.total_tokens_used();
416 if used >= config.max_workflow_tokens {
417 return Err(WorkflowRejection::TokenBudgetExhausted {
418 used,
419 max: config.max_workflow_tokens,
420 });
421 }
422
423 let elapsed = self.started_at.elapsed().as_secs();
424 if elapsed >= config.workflow_timeout_secs {
425 return Err(WorkflowRejection::WorkflowTimeout {
426 elapsed_secs: elapsed,
427 max: config.workflow_timeout_secs,
428 });
429 }
430
431 Ok(())
432 }
433
434 pub(crate) fn enter(
445 &mut self,
446 config: &WorkflowGuardConfig,
447 target_workflow: &str,
448 ) -> Result<(), WorkflowRejection> {
449 self.enter_wave(config, target_workflow, 1)?;
450 self.depth += 1;
451 self.call_chain.push(target_workflow.to_string());
452 Ok(())
453 }
454
455 pub(crate) fn enter_wave(
464 &self,
465 config: &WorkflowGuardConfig,
466 target_workflow: &str,
467 count: u32,
468 ) -> Result<(), WorkflowRejection> {
469 self.check(config, target_workflow)?;
470 self.totals
471 .invocations
472 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |taken| {
473 taken
474 .checked_add(count)
475 .filter(|total| *total <= config.max_fan_out)
476 })
477 .map(|_| ())
478 .map_err(|taken| WorkflowRejection::MaxFanOutExceeded {
479 invocations: taken.saturating_add(count),
480 max: config.max_fan_out,
481 })
482 }
483
484 pub fn record_invocation(&mut self, target_workflow: &str) {
501 self.depth += 1;
502 self.totals.invocations.fetch_add(1, Ordering::Relaxed);
503 self.call_chain.push(target_workflow.to_string());
504 }
505
506 pub fn record_return(&mut self) {
525 self.depth = self.depth.saturating_sub(1);
526 self.call_chain.pop();
527 }
528
529 pub fn record_tokens(
551 &mut self,
552 config: &WorkflowGuardConfig,
553 tokens_used: u64,
554 ) -> Result<u64, WorkflowRejection> {
555 let used = self
556 .totals
557 .tokens_used
558 .fetch_add(tokens_used, Ordering::Relaxed)
559 .saturating_add(tokens_used);
560 if used > config.max_workflow_tokens {
561 return Err(WorkflowRejection::TokenBudgetExhausted {
562 used,
563 max: config.max_workflow_tokens,
564 });
565 }
566 Ok(config.max_workflow_tokens - used)
567 }
568}
569
570impl Default for WorkflowGuardState {
571 fn default() -> Self {
572 Self::new()
573 }
574}
575
576pub type SharedGuardState = Arc<Mutex<WorkflowGuardState>>;
581
582pub fn new_shared_guard_state() -> SharedGuardState {
594 Arc::new(Mutex::new(WorkflowGuardState::new()))
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn default_config_has_documented_values() {
603 let config = WorkflowGuardConfig::default();
604 assert_eq!(config.max_depth, 5);
605 assert_eq!(config.max_fan_out, 20);
606 assert_eq!(config.max_workflow_tokens, 100_000);
607 assert_eq!(config.workflow_timeout_secs, 120);
608 }
609
610 #[test]
611 fn new_equals_default() {
612 assert_eq!(WorkflowGuardConfig::new(), WorkflowGuardConfig::default());
613 }
614
615 #[test]
616 fn config_from_env_reads_and_falls_back() {
617 unsafe {
620 std::env::set_var("IRONFLOW_GUARD_MAX_DEPTH", "10");
621 std::env::set_var("IRONFLOW_GUARD_MAX_FAN_OUT", "50");
622 std::env::set_var("IRONFLOW_GUARD_MAX_WORKFLOW_TOKENS", "200000");
623 std::env::set_var("IRONFLOW_GUARD_WORKFLOW_TIMEOUT_SECS", "300");
624 }
625
626 let config = WorkflowGuardConfig::from_env();
627 assert_eq!(config.max_depth, 10);
628 assert_eq!(config.max_fan_out, 50);
629 assert_eq!(config.max_workflow_tokens, 200_000);
630 assert_eq!(config.workflow_timeout_secs, 300);
631
632 unsafe {
634 std::env::remove_var("IRONFLOW_GUARD_MAX_DEPTH");
635 std::env::remove_var("IRONFLOW_GUARD_MAX_FAN_OUT");
636 std::env::remove_var("IRONFLOW_GUARD_MAX_WORKFLOW_TOKENS");
637 std::env::remove_var("IRONFLOW_GUARD_WORKFLOW_TIMEOUT_SECS");
638 }
639
640 let fallback = WorkflowGuardConfig::from_env();
641 assert_eq!(fallback.max_depth, 5);
642 assert_eq!(fallback.max_fan_out, 20);
643 }
644
645 #[test]
646 fn builder_methods_set_values() {
647 let config = WorkflowGuardConfig::new()
648 .with_max_depth(3)
649 .with_max_fan_out(10)
650 .with_max_workflow_tokens(50_000)
651 .with_workflow_timeout_secs(60);
652
653 assert_eq!(config.max_depth, 3);
654 assert_eq!(config.max_fan_out, 10);
655 assert_eq!(config.max_workflow_tokens, 50_000);
656 assert_eq!(config.workflow_timeout_secs, 60);
657 }
658
659 #[test]
660 fn check_rejects_max_depth_exceeded() {
661 let config = WorkflowGuardConfig::new().with_max_depth(2);
662 let mut state = WorkflowGuardState::new();
663 state.record_invocation("a");
664 state.record_invocation("b");
665
666 let err = state.check(&config, "c").unwrap_err();
667 assert!(matches!(
668 err,
669 WorkflowRejection::MaxDepthExceeded { depth: 2, max: 2 }
670 ));
671 }
672
673 #[test]
674 fn check_allows_within_depth_limit() {
675 let config = WorkflowGuardConfig::new().with_max_depth(2);
676 let mut state = WorkflowGuardState::new();
677 state.record_invocation("a");
678
679 assert!(state.check(&config, "b").is_ok());
680 }
681
682 #[test]
683 fn check_detects_direct_cycle() {
684 let config = WorkflowGuardConfig::default();
685 let mut state = WorkflowGuardState::new();
686 state.record_invocation("workflow-a");
687
688 let err = state.check(&config, "workflow-a").unwrap_err();
689 match err {
690 WorkflowRejection::CycleDetected { target, chain } => {
691 assert_eq!(target, "workflow-a");
692 assert_eq!(chain, vec!["workflow-a"]);
693 }
694 other => panic!("expected CycleDetected, got {other:?}"),
695 }
696 }
697
698 #[test]
699 fn check_detects_indirect_cycle() {
700 let config = WorkflowGuardConfig::default();
701 let mut state = WorkflowGuardState::new();
702 state.record_invocation("a");
703 state.record_invocation("b");
704 state.record_invocation("c");
705
706 let err = state.check(&config, "a").unwrap_err();
707 match err {
708 WorkflowRejection::CycleDetected { target, chain } => {
709 assert_eq!(target, "a");
710 assert_eq!(chain, vec!["a", "b", "c"]);
711 }
712 other => panic!("expected CycleDetected, got {other:?}"),
713 }
714 }
715
716 #[test]
717 fn check_allows_no_cycle() {
718 let config = WorkflowGuardConfig::default();
719 let mut state = WorkflowGuardState::new();
720 state.record_invocation("a");
721 state.record_invocation("b");
722
723 assert!(state.check(&config, "c").is_ok());
724 }
725
726 #[test]
727 fn check_rejects_fan_out_exceeded() {
728 let config = WorkflowGuardConfig::new().with_max_fan_out(2);
729 let mut state = WorkflowGuardState::new();
730 state.record_invocation("a");
731 state.record_return();
732 state.record_invocation("b");
733 state.record_return();
734
735 let err = state.check(&config, "c").unwrap_err();
736 assert!(matches!(
737 err,
738 WorkflowRejection::MaxFanOutExceeded {
739 invocations: 2,
740 max: 2
741 }
742 ));
743 }
744
745 #[test]
746 fn check_rejects_token_budget_exhausted() {
747 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
748 let mut state = WorkflowGuardState::new();
749 let _ = state.record_tokens(&config, 100);
750
751 let err = state.check(&config, "a").unwrap_err();
752 assert!(matches!(
753 err,
754 WorkflowRejection::TokenBudgetExhausted {
755 used: 100,
756 max: 100
757 }
758 ));
759 }
760
761 #[test]
762 fn check_rejects_workflow_timeout() {
763 let state = WorkflowGuardState {
766 started_at: Instant::now() - std::time::Duration::from_secs(200),
767 ..WorkflowGuardState::new()
768 };
769 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
770
771 let err = state.check(&config, "a").unwrap_err();
772 match err {
773 WorkflowRejection::WorkflowTimeout { elapsed_secs, max } => {
774 assert!(elapsed_secs >= 120);
775 assert_eq!(max, 120);
776 }
777 other => panic!("expected WorkflowTimeout, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn check_allows_within_timeout() {
783 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
784 let state = WorkflowGuardState::new();
785 assert!(state.check(&config, "a").is_ok());
786 }
787
788 #[test]
789 fn record_invocation_updates_depth_fanout_chain() {
790 let mut state = WorkflowGuardState::new();
791
792 state.record_invocation("first");
793 assert_eq!(state.depth(), 1);
794 assert_eq!(state.total_invocations(), 1);
795 assert_eq!(state.call_chain(), &["first"]);
796
797 state.record_invocation("second");
798 assert_eq!(state.depth(), 2);
799 assert_eq!(state.total_invocations(), 2);
800 assert_eq!(state.call_chain(), &["first", "second"]);
801 }
802
803 #[test]
804 fn record_return_decrements_depth_and_pops_chain() {
805 let mut state = WorkflowGuardState::new();
806 state.record_invocation("a");
807 state.record_invocation("b");
808
809 state.record_return();
810 assert_eq!(state.depth(), 1);
811 assert_eq!(state.call_chain(), &["a"]);
812
813 state.record_return();
814 assert_eq!(state.depth(), 0);
815 assert!(state.call_chain().is_empty());
816 }
817
818 #[test]
819 fn record_return_saturates_at_zero() {
820 let mut state = WorkflowGuardState::new();
821 state.record_return();
822 assert_eq!(state.depth(), 0);
823 }
824
825 #[test]
826 fn fan_out_counts_even_after_return() {
827 let mut state = WorkflowGuardState::new();
828 state.record_invocation("a");
829 state.record_return();
830 state.record_invocation("b");
831 state.record_return();
832
833 assert_eq!(state.total_invocations(), 2);
834 assert_eq!(state.depth(), 0);
835 }
836
837 #[test]
838 fn record_tokens_accumulates_and_rejects_over_budget() {
839 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
840 let mut state = WorkflowGuardState::new();
841
842 let remaining = state.record_tokens(&config, 40).unwrap();
843 assert_eq!(remaining, 60);
844
845 let remaining = state.record_tokens(&config, 40).unwrap();
846 assert_eq!(remaining, 20);
847
848 let err = state.record_tokens(&config, 30).unwrap_err();
849 assert!(matches!(
850 err,
851 WorkflowRejection::TokenBudgetExhausted {
852 used: 110,
853 max: 100
854 }
855 ));
856 }
857
858 #[test]
859 fn record_tokens_allows_exact_budget() {
860 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
861 let mut state = WorkflowGuardState::new();
862
863 let remaining = state.record_tokens(&config, 100).unwrap();
864 assert_eq!(remaining, 0);
865 }
866
867 #[test]
868 fn rejection_display_max_depth() {
869 let r = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
870 let msg = r.to_string();
871 assert!(msg.contains("max call depth exceeded"));
872 assert!(msg.contains("6/5"));
873 }
874
875 #[test]
876 fn rejection_display_cycle() {
877 let r = WorkflowRejection::CycleDetected {
878 target: "b".to_string(),
879 chain: vec!["a".to_string(), "b".to_string()],
880 };
881 let msg = r.to_string();
882 assert!(msg.contains("cycle detected"));
883 assert!(msg.contains("\"b\""));
884 }
885
886 #[test]
887 fn rejection_display_fan_out() {
888 let r = WorkflowRejection::MaxFanOutExceeded {
889 invocations: 21,
890 max: 20,
891 };
892 let msg = r.to_string();
893 assert!(msg.contains("max fan-out exceeded"));
894 assert!(msg.contains("21/20"));
895 }
896
897 #[test]
898 fn rejection_display_token_budget() {
899 let r = WorkflowRejection::TokenBudgetExhausted {
900 used: 100_001,
901 max: 100_000,
902 };
903 let msg = r.to_string();
904 assert!(msg.contains("token budget exhausted"));
905 assert!(msg.contains("100001/100000"));
906 }
907
908 #[test]
909 fn rejection_display_timeout() {
910 let r = WorkflowRejection::WorkflowTimeout {
911 elapsed_secs: 130,
912 max: 120,
913 };
914 let msg = r.to_string();
915 assert!(msg.contains("workflow timeout"));
916 assert!(msg.contains("130s/120s"));
917 }
918
919 #[test]
920 fn rejection_display_unavailable() {
921 let r = WorkflowRejection::GuardUnavailable;
922 assert!(r.to_string().contains("failing closed"));
923 }
924
925 #[test]
926 fn shared_guard_state_is_arc_mutex() {
927 let shared = new_shared_guard_state();
928 let mut state = shared.lock().unwrap();
929 state.record_invocation("test");
930 assert_eq!(state.depth(), 1);
931 }
932
933 #[test]
934 fn a_branch_keeps_its_own_chain_and_shares_the_totals() {
935 let mut root = WorkflowGuardState::new();
936 root.record_invocation("parent");
937 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(10);
938 root.enter_wave(&config, "child", 2).expect("room for two");
939 let mut left = root.branch_into("child");
940 let mut right = root.branch_into("child");
941 left.enter(&config, "leaf").expect("no cycle on the left");
942
943 assert_eq!(left.call_chain(), ["parent", "child", "leaf"]);
944 assert_eq!(right.call_chain(), ["parent", "child"]);
945 assert_eq!(right.depth(), 2);
946 assert_eq!(root.call_chain(), ["parent"]);
947 assert_eq!(root.total_invocations(), 4);
948
949 assert_eq!(left.record_tokens(&config, 6), Ok(4));
950 assert!(
951 right.record_tokens(&config, 6).is_err(),
952 "tokens are shared"
953 );
954 }
955
956 #[test]
957 fn enter_wave_claims_room_for_every_child_or_none() {
958 let mut state = WorkflowGuardState::new();
959 state.record_invocation("earlier");
960 state.record_return();
961 let config = WorkflowGuardConfig::new().with_max_fan_out(3);
962
963 assert_eq!(
964 state.enter_wave(&config, "child", 3),
965 Err(WorkflowRejection::MaxFanOutExceeded {
966 invocations: 4,
967 max: 3
968 })
969 );
970 assert_eq!(
971 state.total_invocations(),
972 1,
973 "a refused wave claims nothing"
974 );
975 assert_eq!(state.enter_wave(&config, "child", 2), Ok(()));
976 assert_eq!(state.total_invocations(), 3);
977 }
978
979 #[test]
980 fn enter_claims_the_last_slot_once_across_branches() {
981 let root = WorkflowGuardState::new();
982 let config = WorkflowGuardConfig::new().with_max_fan_out(3);
983 root.enter_wave(&config, "child", 2).expect("room for two");
984 let mut left = root.branch_into("child");
985 let mut right = root.branch_into("child");
986
987 assert_eq!(left.enter(&config, "leaf"), Ok(()));
988 assert_eq!(
989 right.enter(&config, "leaf"),
990 Err(WorkflowRejection::MaxFanOutExceeded {
991 invocations: 3,
992 max: 3
993 })
994 );
995 assert_eq!(right.depth(), 1, "a refused invocation records nothing");
996 assert_eq!(right.call_chain(), ["child"]);
997 }
998}