1use std::fmt;
25use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29use chrono::{DateTime, TimeDelta, Utc};
30
31const ENV_PREFIX: &str = "IRONFLOW_GUARD_";
33
34#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct WorkflowGuardConfig {
55 pub max_depth: u32,
57 pub max_fan_out: u32,
59 pub max_workflow_tokens: u64,
62 pub workflow_timeout_secs: u64,
65 pub run_deadline: Option<Duration>,
68}
69
70impl Default for WorkflowGuardConfig {
71 fn default() -> Self {
72 Self {
73 max_depth: 5,
74 max_fan_out: 20,
75 max_workflow_tokens: 100_000,
76 workflow_timeout_secs: 120,
77 run_deadline: None,
78 }
79 }
80}
81
82impl WorkflowGuardConfig {
83 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn from_env() -> Self {
112 Self {
113 max_depth: parse_env("MAX_DEPTH", 5),
114 max_fan_out: parse_env("MAX_FAN_OUT", 20),
115 max_workflow_tokens: parse_env("MAX_WORKFLOW_TOKENS", 100_000),
116 workflow_timeout_secs: parse_env("WORKFLOW_TIMEOUT_SECS", 120),
117 run_deadline: None,
118 }
119 }
120
121 pub fn with_max_depth(mut self, max_depth: u32) -> Self {
132 self.max_depth = max_depth;
133 self
134 }
135
136 pub fn with_max_fan_out(mut self, max_fan_out: u32) -> Self {
147 self.max_fan_out = max_fan_out;
148 self
149 }
150
151 pub fn with_max_workflow_tokens(mut self, max: u64) -> Self {
162 self.max_workflow_tokens = max;
163 self
164 }
165
166 pub fn with_workflow_timeout_secs(mut self, secs: u64) -> Self {
177 self.workflow_timeout_secs = secs;
178 self
179 }
180
181 pub fn with_run_deadline(mut self, deadline: Duration) -> Self {
199 self.run_deadline = Some(deadline);
200 self
201 }
202
203 pub fn deadline_for(&self, created_at: DateTime<Utc>) -> Option<DateTime<Utc>> {
225 self.run_deadline
226 .and_then(|d| TimeDelta::from_std(d).ok())
227 .and_then(|d| created_at.checked_add_signed(d))
228 }
229}
230
231fn parse_env<T: std::str::FromStr>(suffix: &str, default: T) -> T {
232 std::env::var(format!("{ENV_PREFIX}{suffix}"))
233 .ok()
234 .and_then(|v| v.parse().ok())
235 .unwrap_or(default)
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum WorkflowRejection {
254 MaxDepthExceeded {
256 depth: u32,
258 max: u32,
260 },
261 CycleDetected {
263 target: String,
265 chain: Vec<String>,
267 },
268 MaxFanOutExceeded {
270 invocations: u32,
272 max: u32,
274 },
275 TokenBudgetExhausted {
277 used: u64,
279 max: u64,
281 },
282 WorkflowTimeout {
284 elapsed_secs: u64,
286 max: u64,
288 },
289 RunDeadlineReached {
292 deadline_at: DateTime<Utc>,
294 },
295 GuardUnavailable,
297}
298
299pub const WORKFLOW_GUARD_REJECTED_CODE: &str = "WORKFLOW_GUARD_REJECTED";
301
302impl fmt::Display for WorkflowRejection {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 match self {
305 Self::MaxDepthExceeded { depth, max } => {
306 write!(f, "max call depth exceeded: {depth}/{max}")
307 }
308 Self::CycleDetected { target, chain } => {
309 write!(
310 f,
311 "cycle detected: workflow {target:?} already in chain {chain:?}"
312 )
313 }
314 Self::MaxFanOutExceeded { invocations, max } => {
315 write!(f, "max fan-out exceeded: {invocations}/{max} invocations")
316 }
317 Self::TokenBudgetExhausted { used, max } => {
318 write!(f, "token budget exhausted: {used}/{max}")
319 }
320 Self::WorkflowTimeout { elapsed_secs, max } => {
321 write!(f, "workflow timeout: {elapsed_secs}s/{max}s")
322 }
323 Self::RunDeadlineReached { deadline_at } => {
324 write!(f, "run deadline reached at {}", deadline_at.to_rfc3339())
325 }
326 Self::GuardUnavailable => {
327 write!(f, "workflow guard unavailable -- failing closed")
328 }
329 }
330 }
331}
332
333impl std::error::Error for WorkflowRejection {}
334
335#[derive(Debug)]
353pub struct WorkflowGuardState {
354 depth: u32,
356 call_chain: Vec<String>,
358 totals: Arc<GuardTotals>,
360 started_at: Instant,
362 carried_elapsed: Duration,
364 deadline_at: Option<DateTime<Utc>>,
366}
367
368#[derive(Debug, Default)]
371struct GuardTotals {
372 invocations: AtomicU32,
374 tokens_used: AtomicU64,
376}
377
378impl WorkflowGuardState {
379 pub fn new() -> Self {
391 Self {
392 depth: 0,
393 call_chain: Vec::new(),
394 totals: Arc::new(GuardTotals::default()),
395 started_at: Instant::now(),
396 carried_elapsed: Duration::ZERO,
397 deadline_at: None,
398 }
399 }
400
401 pub fn with_tokens_used(self, tokens: u64) -> Self {
414 self.totals.tokens_used.store(tokens, Ordering::Relaxed);
415 self
416 }
417
418 pub fn with_elapsed(mut self, elapsed: Duration) -> Self {
431 self.carried_elapsed = elapsed;
432 self
433 }
434
435 pub fn with_deadline_at(mut self, deadline_at: DateTime<Utc>) -> Self {
448 self.deadline_at = Some(deadline_at);
449 self
450 }
451
452 pub fn deadline_at(&self) -> Option<DateTime<Utc>> {
462 self.deadline_at
463 }
464
465 pub(crate) fn branch_into(&self, target_workflow: &str) -> Self {
474 let mut call_chain = self.call_chain.clone();
475 call_chain.push(target_workflow.to_string());
476 Self {
477 depth: self.depth + 1,
478 call_chain,
479 totals: self.totals.clone(),
480 started_at: self.started_at,
481 carried_elapsed: self.carried_elapsed,
482 deadline_at: self.deadline_at,
483 }
484 }
485
486 pub fn depth(&self) -> u32 {
488 self.depth
489 }
490
491 pub fn total_invocations(&self) -> u32 {
493 self.totals.invocations.load(Ordering::Relaxed)
494 }
495
496 pub fn total_tokens_used(&self) -> u64 {
498 self.totals.tokens_used.load(Ordering::Relaxed)
499 }
500
501 pub fn call_chain(&self) -> &[String] {
503 &self.call_chain
504 }
505
506 pub fn elapsed_secs(&self) -> u64 {
509 self.elapsed().as_secs()
510 }
511
512 fn elapsed(&self) -> Duration {
513 let current = self.started_at.elapsed();
514 self.carried_elapsed.saturating_add(current)
515 }
516
517 pub(crate) fn check_time(&self, config: &WorkflowGuardConfig) -> Result<(), WorkflowRejection> {
525 let elapsed_secs = self.elapsed_secs();
526 if elapsed_secs >= config.workflow_timeout_secs {
527 return Err(WorkflowRejection::WorkflowTimeout {
528 elapsed_secs,
529 max: config.workflow_timeout_secs,
530 });
531 }
532 if let Some(deadline_at) = self.deadline_at
533 && Utc::now() >= deadline_at
534 {
535 return Err(WorkflowRejection::RunDeadlineReached { deadline_at });
536 }
537 Ok(())
538 }
539
540 pub(crate) fn time_limit(&self, config: &WorkflowGuardConfig) -> (Duration, WorkflowRejection) {
543 let max = Duration::from_secs(config.workflow_timeout_secs);
544 let timeout = (
545 max.saturating_sub(self.elapsed()),
546 WorkflowRejection::WorkflowTimeout {
547 elapsed_secs: config.workflow_timeout_secs,
548 max: config.workflow_timeout_secs,
549 },
550 );
551 let Some(deadline_at) = self.deadline_at else {
552 return timeout;
553 };
554 let left = (deadline_at - Utc::now()).to_std();
556 let left = left.unwrap_or(Duration::ZERO);
557 if left < timeout.0 {
558 (left, WorkflowRejection::RunDeadlineReached { deadline_at })
559 } else {
560 timeout
561 }
562 }
563
564 pub fn check(
586 &self,
587 config: &WorkflowGuardConfig,
588 target_workflow: &str,
589 ) -> Result<(), WorkflowRejection> {
590 if self.depth >= config.max_depth {
591 return Err(WorkflowRejection::MaxDepthExceeded {
592 depth: self.depth,
593 max: config.max_depth,
594 });
595 }
596
597 if self.call_chain.iter().any(|id| id == target_workflow) {
598 return Err(WorkflowRejection::CycleDetected {
599 target: target_workflow.to_string(),
600 chain: self.call_chain.clone(),
601 });
602 }
603
604 let invocations = self.total_invocations();
605 if invocations >= config.max_fan_out {
606 return Err(WorkflowRejection::MaxFanOutExceeded {
607 invocations,
608 max: config.max_fan_out,
609 });
610 }
611
612 let used = self.total_tokens_used();
613 if used >= config.max_workflow_tokens {
614 return Err(WorkflowRejection::TokenBudgetExhausted {
615 used,
616 max: config.max_workflow_tokens,
617 });
618 }
619
620 self.check_time(config)
621 }
622
623 pub(crate) fn enter(
634 &mut self,
635 config: &WorkflowGuardConfig,
636 target_workflow: &str,
637 ) -> Result<(), WorkflowRejection> {
638 self.enter_wave(config, target_workflow, 1)?;
639 self.depth += 1;
640 self.call_chain.push(target_workflow.to_string());
641 Ok(())
642 }
643
644 pub(crate) fn enter_wave(
653 &self,
654 config: &WorkflowGuardConfig,
655 target_workflow: &str,
656 count: u32,
657 ) -> Result<(), WorkflowRejection> {
658 self.check(config, target_workflow)?;
659 self.totals
660 .invocations
661 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |taken| {
662 taken
663 .checked_add(count)
664 .filter(|total| *total <= config.max_fan_out)
665 })
666 .map(|_| ())
667 .map_err(|taken| WorkflowRejection::MaxFanOutExceeded {
668 invocations: taken.saturating_add(count),
669 max: config.max_fan_out,
670 })
671 }
672
673 pub fn record_invocation(&mut self, target_workflow: &str) {
690 self.depth += 1;
691 self.totals.invocations.fetch_add(1, Ordering::Relaxed);
692 self.call_chain.push(target_workflow.to_string());
693 }
694
695 pub fn record_return(&mut self) {
714 self.depth = self.depth.saturating_sub(1);
715 self.call_chain.pop();
716 }
717
718 pub fn record_tokens(
740 &mut self,
741 config: &WorkflowGuardConfig,
742 tokens_used: u64,
743 ) -> Result<u64, WorkflowRejection> {
744 let used = self
745 .totals
746 .tokens_used
747 .fetch_add(tokens_used, Ordering::Relaxed)
748 .saturating_add(tokens_used);
749 if used > config.max_workflow_tokens {
750 return Err(WorkflowRejection::TokenBudgetExhausted {
751 used,
752 max: config.max_workflow_tokens,
753 });
754 }
755 Ok(config.max_workflow_tokens - used)
756 }
757}
758
759impl Default for WorkflowGuardState {
760 fn default() -> Self {
761 Self::new()
762 }
763}
764
765pub type SharedGuardState = Arc<Mutex<WorkflowGuardState>>;
770
771pub fn new_shared_guard_state() -> SharedGuardState {
783 Arc::new(Mutex::new(WorkflowGuardState::new()))
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn default_config_has_documented_values() {
792 let config = WorkflowGuardConfig::default();
793 assert_eq!(config.max_depth, 5);
794 assert_eq!(config.max_fan_out, 20);
795 assert_eq!(config.max_workflow_tokens, 100_000);
796 assert_eq!(config.workflow_timeout_secs, 120);
797 assert_eq!(config.run_deadline, None);
798 }
799
800 #[test]
801 fn new_equals_default() {
802 assert_eq!(WorkflowGuardConfig::new(), WorkflowGuardConfig::default());
803 }
804
805 #[test]
806 fn config_from_env_reads_and_falls_back() {
807 unsafe {
810 std::env::set_var("IRONFLOW_GUARD_MAX_DEPTH", "10");
811 std::env::set_var("IRONFLOW_GUARD_MAX_FAN_OUT", "50");
812 std::env::set_var("IRONFLOW_GUARD_MAX_WORKFLOW_TOKENS", "200000");
813 std::env::set_var("IRONFLOW_GUARD_WORKFLOW_TIMEOUT_SECS", "300");
814 }
815
816 let config = WorkflowGuardConfig::from_env();
817 assert_eq!(config.max_depth, 10);
818 assert_eq!(config.max_fan_out, 50);
819 assert_eq!(config.max_workflow_tokens, 200_000);
820 assert_eq!(config.workflow_timeout_secs, 300);
821
822 unsafe {
824 std::env::remove_var("IRONFLOW_GUARD_MAX_DEPTH");
825 std::env::remove_var("IRONFLOW_GUARD_MAX_FAN_OUT");
826 std::env::remove_var("IRONFLOW_GUARD_MAX_WORKFLOW_TOKENS");
827 std::env::remove_var("IRONFLOW_GUARD_WORKFLOW_TIMEOUT_SECS");
828 }
829
830 let fallback = WorkflowGuardConfig::from_env();
831 assert_eq!(fallback.max_depth, 5);
832 assert_eq!(fallback.max_fan_out, 20);
833 }
834
835 #[test]
836 fn builder_methods_set_values() {
837 let config = WorkflowGuardConfig::new()
838 .with_max_depth(3)
839 .with_max_fan_out(10)
840 .with_max_workflow_tokens(50_000)
841 .with_workflow_timeout_secs(60)
842 .with_run_deadline(Duration::from_secs(3600));
843
844 assert_eq!(config.max_depth, 3);
845 assert_eq!(config.max_fan_out, 10);
846 assert_eq!(config.max_workflow_tokens, 50_000);
847 assert_eq!(config.workflow_timeout_secs, 60);
848 assert_eq!(config.run_deadline, Some(Duration::from_secs(3600)));
849 }
850
851 #[test]
852 fn with_tokens_used_counts_toward_the_budget() {
853 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(1500);
854 let mut state = WorkflowGuardState::new().with_tokens_used(700);
855
856 assert_eq!(state.record_tokens(&config, 800), Ok(0));
857 assert!(matches!(
858 state.record_tokens(&config, 1),
859 Err(WorkflowRejection::TokenBudgetExhausted {
860 used: 1501,
861 max: 1500
862 })
863 ));
864 }
865
866 #[test]
867 fn check_rejects_timeout_reached_by_earlier_executions() {
868 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
869 let elapsed = Duration::from_secs(200);
870 let state = WorkflowGuardState::new().with_elapsed(elapsed);
871
872 let err = state.check(&config, "a").unwrap_err();
873 match err {
874 WorkflowRejection::WorkflowTimeout { elapsed_secs, max } => {
875 assert!(elapsed_secs >= 200);
876 assert_eq!(max, 120);
877 }
878 other => panic!("expected WorkflowTimeout, got {other:?}"),
879 }
880 }
881
882 #[test]
883 fn check_rejects_a_past_deadline() {
884 let deadline_at = Utc::now() - TimeDelta::seconds(1);
885 let state = WorkflowGuardState::new().with_deadline_at(deadline_at);
886
887 assert_eq!(
888 state.check(&WorkflowGuardConfig::new(), "a"),
889 Err(WorkflowRejection::RunDeadlineReached { deadline_at })
890 );
891 }
892
893 #[test]
894 fn check_allows_a_future_deadline() {
895 let deadline_at = Utc::now() + TimeDelta::hours(1);
896 let state = WorkflowGuardState::new().with_deadline_at(deadline_at);
897 assert!(state.check(&WorkflowGuardConfig::new(), "a").is_ok());
898 }
899
900 #[test]
901 fn time_limit_picks_the_nearest_limit() {
902 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
903 let deadline_at = Utc::now() + TimeDelta::seconds(2);
904 let state = WorkflowGuardState::new().with_deadline_at(deadline_at);
905
906 let (left, rejection) = state.time_limit(&config);
907 assert!(left <= Duration::from_secs(2));
908 assert_eq!(
909 rejection,
910 WorkflowRejection::RunDeadlineReached { deadline_at }
911 );
912
913 let (left, rejection) = WorkflowGuardState::new().time_limit(&config);
914 assert!(left <= Duration::from_secs(120));
915 assert!(left > Duration::from_secs(100));
916 assert!(matches!(
917 rejection,
918 WorkflowRejection::WorkflowTimeout { max: 120, .. }
919 ));
920 }
921
922 #[test]
923 fn time_limit_is_zero_past_the_deadline() {
924 let config = WorkflowGuardConfig::new();
925 let deadline_at = Utc::now() - TimeDelta::seconds(5);
926 let state = WorkflowGuardState::new().with_deadline_at(deadline_at);
927
928 assert_eq!(
929 state.time_limit(&config),
930 (
931 Duration::ZERO,
932 WorkflowRejection::RunDeadlineReached { deadline_at }
933 )
934 );
935 }
936
937 #[test]
938 fn branch_shares_carried_time_and_deadline() {
939 let deadline_at = Utc::now() + TimeDelta::hours(1);
940 let root = WorkflowGuardState::new()
941 .with_elapsed(Duration::from_secs(50))
942 .with_deadline_at(deadline_at);
943 let branch = root.branch_into("child");
944
945 assert!(branch.elapsed_secs() >= 50);
946 assert_eq!(branch.deadline_at(), Some(deadline_at));
947 }
948
949 #[test]
950 fn deadline_for_adds_the_deadline_to_creation() {
951 let created_at = Utc::now();
952 let deadline = Duration::from_secs(90);
953 let config = WorkflowGuardConfig::new().with_run_deadline(deadline);
954 assert_eq!(
955 config.deadline_for(created_at),
956 Some(created_at + TimeDelta::seconds(90))
957 );
958 }
959
960 #[test]
961 fn deadline_for_none_without_deadline() {
962 assert_eq!(WorkflowGuardConfig::new().deadline_for(Utc::now()), None);
963 }
964
965 #[test]
966 fn deadline_for_none_on_overflow() {
967 let config = WorkflowGuardConfig::new().with_run_deadline(Duration::MAX);
968 assert_eq!(config.deadline_for(Utc::now()), None);
969 }
970
971 #[test]
972 fn rejection_display_run_deadline() {
973 let deadline_at = Utc::now();
974 let rejection = WorkflowRejection::RunDeadlineReached { deadline_at };
975 let msg = rejection.to_string();
976 assert!(msg.contains("run deadline reached at"));
977 assert!(msg.contains(&deadline_at.to_rfc3339()));
978 }
979
980 #[test]
981 fn check_rejects_max_depth_exceeded() {
982 let config = WorkflowGuardConfig::new().with_max_depth(2);
983 let mut state = WorkflowGuardState::new();
984 state.record_invocation("a");
985 state.record_invocation("b");
986
987 let err = state.check(&config, "c").unwrap_err();
988 assert!(matches!(
989 err,
990 WorkflowRejection::MaxDepthExceeded { depth: 2, max: 2 }
991 ));
992 }
993
994 #[test]
995 fn check_allows_within_depth_limit() {
996 let config = WorkflowGuardConfig::new().with_max_depth(2);
997 let mut state = WorkflowGuardState::new();
998 state.record_invocation("a");
999
1000 assert!(state.check(&config, "b").is_ok());
1001 }
1002
1003 #[test]
1004 fn check_detects_direct_cycle() {
1005 let config = WorkflowGuardConfig::default();
1006 let mut state = WorkflowGuardState::new();
1007 state.record_invocation("workflow-a");
1008
1009 let err = state.check(&config, "workflow-a").unwrap_err();
1010 match err {
1011 WorkflowRejection::CycleDetected { target, chain } => {
1012 assert_eq!(target, "workflow-a");
1013 assert_eq!(chain, vec!["workflow-a"]);
1014 }
1015 other => panic!("expected CycleDetected, got {other:?}"),
1016 }
1017 }
1018
1019 #[test]
1020 fn check_detects_indirect_cycle() {
1021 let config = WorkflowGuardConfig::default();
1022 let mut state = WorkflowGuardState::new();
1023 state.record_invocation("a");
1024 state.record_invocation("b");
1025 state.record_invocation("c");
1026
1027 let err = state.check(&config, "a").unwrap_err();
1028 match err {
1029 WorkflowRejection::CycleDetected { target, chain } => {
1030 assert_eq!(target, "a");
1031 assert_eq!(chain, vec!["a", "b", "c"]);
1032 }
1033 other => panic!("expected CycleDetected, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1038 fn check_allows_no_cycle() {
1039 let config = WorkflowGuardConfig::default();
1040 let mut state = WorkflowGuardState::new();
1041 state.record_invocation("a");
1042 state.record_invocation("b");
1043
1044 assert!(state.check(&config, "c").is_ok());
1045 }
1046
1047 #[test]
1048 fn check_rejects_fan_out_exceeded() {
1049 let config = WorkflowGuardConfig::new().with_max_fan_out(2);
1050 let mut state = WorkflowGuardState::new();
1051 state.record_invocation("a");
1052 state.record_return();
1053 state.record_invocation("b");
1054 state.record_return();
1055
1056 let err = state.check(&config, "c").unwrap_err();
1057 assert!(matches!(
1058 err,
1059 WorkflowRejection::MaxFanOutExceeded {
1060 invocations: 2,
1061 max: 2
1062 }
1063 ));
1064 }
1065
1066 #[test]
1067 fn check_rejects_token_budget_exhausted() {
1068 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
1069 let mut state = WorkflowGuardState::new();
1070 let _ = state.record_tokens(&config, 100);
1071
1072 let err = state.check(&config, "a").unwrap_err();
1073 assert!(matches!(
1074 err,
1075 WorkflowRejection::TokenBudgetExhausted {
1076 used: 100,
1077 max: 100
1078 }
1079 ));
1080 }
1081
1082 #[test]
1083 fn check_rejects_workflow_timeout() {
1084 let state = WorkflowGuardState {
1087 started_at: Instant::now() - std::time::Duration::from_secs(200),
1088 ..WorkflowGuardState::new()
1089 };
1090 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
1091
1092 let err = state.check(&config, "a").unwrap_err();
1093 match err {
1094 WorkflowRejection::WorkflowTimeout { elapsed_secs, max } => {
1095 assert!(elapsed_secs >= 120);
1096 assert_eq!(max, 120);
1097 }
1098 other => panic!("expected WorkflowTimeout, got {other:?}"),
1099 }
1100 }
1101
1102 #[test]
1103 fn check_allows_within_timeout() {
1104 let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(120);
1105 let state = WorkflowGuardState::new();
1106 assert!(state.check(&config, "a").is_ok());
1107 }
1108
1109 #[test]
1110 fn record_invocation_updates_depth_fanout_chain() {
1111 let mut state = WorkflowGuardState::new();
1112
1113 state.record_invocation("first");
1114 assert_eq!(state.depth(), 1);
1115 assert_eq!(state.total_invocations(), 1);
1116 assert_eq!(state.call_chain(), &["first"]);
1117
1118 state.record_invocation("second");
1119 assert_eq!(state.depth(), 2);
1120 assert_eq!(state.total_invocations(), 2);
1121 assert_eq!(state.call_chain(), &["first", "second"]);
1122 }
1123
1124 #[test]
1125 fn record_return_decrements_depth_and_pops_chain() {
1126 let mut state = WorkflowGuardState::new();
1127 state.record_invocation("a");
1128 state.record_invocation("b");
1129
1130 state.record_return();
1131 assert_eq!(state.depth(), 1);
1132 assert_eq!(state.call_chain(), &["a"]);
1133
1134 state.record_return();
1135 assert_eq!(state.depth(), 0);
1136 assert!(state.call_chain().is_empty());
1137 }
1138
1139 #[test]
1140 fn record_return_saturates_at_zero() {
1141 let mut state = WorkflowGuardState::new();
1142 state.record_return();
1143 assert_eq!(state.depth(), 0);
1144 }
1145
1146 #[test]
1147 fn fan_out_counts_even_after_return() {
1148 let mut state = WorkflowGuardState::new();
1149 state.record_invocation("a");
1150 state.record_return();
1151 state.record_invocation("b");
1152 state.record_return();
1153
1154 assert_eq!(state.total_invocations(), 2);
1155 assert_eq!(state.depth(), 0);
1156 }
1157
1158 #[test]
1159 fn record_tokens_accumulates_and_rejects_over_budget() {
1160 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
1161 let mut state = WorkflowGuardState::new();
1162
1163 let remaining = state.record_tokens(&config, 40).unwrap();
1164 assert_eq!(remaining, 60);
1165
1166 let remaining = state.record_tokens(&config, 40).unwrap();
1167 assert_eq!(remaining, 20);
1168
1169 let err = state.record_tokens(&config, 30).unwrap_err();
1170 assert!(matches!(
1171 err,
1172 WorkflowRejection::TokenBudgetExhausted {
1173 used: 110,
1174 max: 100
1175 }
1176 ));
1177 }
1178
1179 #[test]
1180 fn record_tokens_allows_exact_budget() {
1181 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
1182 let mut state = WorkflowGuardState::new();
1183
1184 let remaining = state.record_tokens(&config, 100).unwrap();
1185 assert_eq!(remaining, 0);
1186 }
1187
1188 #[test]
1189 fn rejection_display_max_depth() {
1190 let r = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
1191 let msg = r.to_string();
1192 assert!(msg.contains("max call depth exceeded"));
1193 assert!(msg.contains("6/5"));
1194 }
1195
1196 #[test]
1197 fn rejection_display_cycle() {
1198 let r = WorkflowRejection::CycleDetected {
1199 target: "b".to_string(),
1200 chain: vec!["a".to_string(), "b".to_string()],
1201 };
1202 let msg = r.to_string();
1203 assert!(msg.contains("cycle detected"));
1204 assert!(msg.contains("\"b\""));
1205 }
1206
1207 #[test]
1208 fn rejection_display_fan_out() {
1209 let r = WorkflowRejection::MaxFanOutExceeded {
1210 invocations: 21,
1211 max: 20,
1212 };
1213 let msg = r.to_string();
1214 assert!(msg.contains("max fan-out exceeded"));
1215 assert!(msg.contains("21/20"));
1216 }
1217
1218 #[test]
1219 fn rejection_display_token_budget() {
1220 let r = WorkflowRejection::TokenBudgetExhausted {
1221 used: 100_001,
1222 max: 100_000,
1223 };
1224 let msg = r.to_string();
1225 assert!(msg.contains("token budget exhausted"));
1226 assert!(msg.contains("100001/100000"));
1227 }
1228
1229 #[test]
1230 fn rejection_display_timeout() {
1231 let r = WorkflowRejection::WorkflowTimeout {
1232 elapsed_secs: 130,
1233 max: 120,
1234 };
1235 let msg = r.to_string();
1236 assert!(msg.contains("workflow timeout"));
1237 assert!(msg.contains("130s/120s"));
1238 }
1239
1240 #[test]
1241 fn rejection_display_unavailable() {
1242 let r = WorkflowRejection::GuardUnavailable;
1243 assert!(r.to_string().contains("failing closed"));
1244 }
1245
1246 #[test]
1247 fn shared_guard_state_is_arc_mutex() {
1248 let shared = new_shared_guard_state();
1249 let mut state = shared.lock().unwrap();
1250 state.record_invocation("test");
1251 assert_eq!(state.depth(), 1);
1252 }
1253
1254 #[test]
1255 fn a_branch_keeps_its_own_chain_and_shares_the_totals() {
1256 let mut root = WorkflowGuardState::new();
1257 root.record_invocation("parent");
1258 let config = WorkflowGuardConfig::new().with_max_workflow_tokens(10);
1259 root.enter_wave(&config, "child", 2).expect("room for two");
1260 let mut left = root.branch_into("child");
1261 let mut right = root.branch_into("child");
1262 left.enter(&config, "leaf").expect("no cycle on the left");
1263
1264 assert_eq!(left.call_chain(), ["parent", "child", "leaf"]);
1265 assert_eq!(right.call_chain(), ["parent", "child"]);
1266 assert_eq!(right.depth(), 2);
1267 assert_eq!(root.call_chain(), ["parent"]);
1268 assert_eq!(root.total_invocations(), 4);
1269
1270 assert_eq!(left.record_tokens(&config, 6), Ok(4));
1271 assert!(
1272 right.record_tokens(&config, 6).is_err(),
1273 "tokens are shared"
1274 );
1275 }
1276
1277 #[test]
1278 fn enter_wave_claims_room_for_every_child_or_none() {
1279 let mut state = WorkflowGuardState::new();
1280 state.record_invocation("earlier");
1281 state.record_return();
1282 let config = WorkflowGuardConfig::new().with_max_fan_out(3);
1283
1284 assert_eq!(
1285 state.enter_wave(&config, "child", 3),
1286 Err(WorkflowRejection::MaxFanOutExceeded {
1287 invocations: 4,
1288 max: 3
1289 })
1290 );
1291 assert_eq!(
1292 state.total_invocations(),
1293 1,
1294 "a refused wave claims nothing"
1295 );
1296 assert_eq!(state.enter_wave(&config, "child", 2), Ok(()));
1297 assert_eq!(state.total_invocations(), 3);
1298 }
1299
1300 #[test]
1301 fn enter_claims_the_last_slot_once_across_branches() {
1302 let root = WorkflowGuardState::new();
1303 let config = WorkflowGuardConfig::new().with_max_fan_out(3);
1304 root.enter_wave(&config, "child", 2).expect("room for two");
1305 let mut left = root.branch_into("child");
1306 let mut right = root.branch_into("child");
1307
1308 assert_eq!(left.enter(&config, "leaf"), Ok(()));
1309 assert_eq!(
1310 right.enter(&config, "leaf"),
1311 Err(WorkflowRejection::MaxFanOutExceeded {
1312 invocations: 3,
1313 max: 3
1314 })
1315 );
1316 assert_eq!(right.depth(), 1, "a refused invocation records nothing");
1317 assert_eq!(right.call_chain(), ["child"]);
1318 }
1319}