Skip to main content

ironflow_engine/
guard.rs

1//! Workflow guard -- execution limits for agent workflows.
2//!
3//! Prevents runaway workflows: unbounded sub-workflow depth, uncontrolled
4//! fan-out, unlimited token consumption, and infinite execution time.
5//! Checked before every agent step invocation.
6//!
7//! Tokens and active execution time add up over every execution of a run
8//! (resume, requeue, retry): the engine seeds the state of each execution
9//! from what earlier ones stored. An optional absolute deadline
10//! ([`WorkflowGuardConfig::run_deadline`]) also stops a run that sleeps.
11//!
12//! # Examples
13//!
14//! ```
15//! use ironflow_engine::guard::{WorkflowGuardConfig, WorkflowGuardState, WorkflowRejection};
16//!
17//! let config = WorkflowGuardConfig::default();
18//! assert_eq!(config.max_depth, 5);
19//!
20//! let state = WorkflowGuardState::new();
21//! assert!(state.check(&config, "agent-a").is_ok());
22//! ```
23
24use 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
31/// Environment variable prefix for guard configuration.
32const ENV_PREFIX: &str = "IRONFLOW_GUARD_";
33
34/// Configurable execution limits for a workflow run.
35///
36/// Each limit has a sensible default. Limits can be set per-handler via
37/// [`WorkflowHandler::guard_config`](crate::handler::WorkflowHandler::guard_config)
38/// or globally on the [`Engine`](crate::engine::Engine).
39///
40/// # Examples
41///
42/// ```
43/// use ironflow_engine::guard::WorkflowGuardConfig;
44///
45/// let config = WorkflowGuardConfig::new()
46///     .with_max_depth(3)
47///     .with_max_fan_out(10);
48///
49/// assert_eq!(config.max_depth, 3);
50/// assert_eq!(config.max_fan_out, 10);
51/// assert_eq!(config.max_workflow_tokens, 100_000);
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct WorkflowGuardConfig {
55    /// Maximum depth of nested sub-workflows (default 5).
56    pub max_depth: u32,
57    /// Maximum total number of workflow invocations (default 20).
58    pub max_fan_out: u32,
59    /// Cumulative token budget across all agent steps and every execution
60    /// of the run (default 100,000).
61    pub max_workflow_tokens: u64,
62    /// Global timeout in seconds of active execution time, summed over every
63    /// execution of the run, sleeping excluded (default 120).
64    pub workflow_timeout_secs: u64,
65    /// Absolute deadline of a run, counted from its creation
66    /// (`Run.created_at`), sleeping time included (default `None`).
67    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    /// Create a configuration with default limits.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use ironflow_engine::guard::WorkflowGuardConfig;
89    ///
90    /// assert_eq!(WorkflowGuardConfig::new(), WorkflowGuardConfig::default());
91    /// ```
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Load configuration from environment variables.
97    ///
98    /// Reads `IRONFLOW_GUARD_MAX_DEPTH`, `IRONFLOW_GUARD_MAX_FAN_OUT`,
99    /// `IRONFLOW_GUARD_MAX_WORKFLOW_TOKENS`, and `IRONFLOW_GUARD_WORKFLOW_TIMEOUT_SECS`.
100    /// Missing or unparseable variables fall back to defaults.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use ironflow_engine::guard::WorkflowGuardConfig;
106    ///
107    /// let config = WorkflowGuardConfig::from_env();
108    /// // Without env vars set, returns defaults.
109    /// assert_eq!(config.max_depth, 5);
110    /// ```
111    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    /// Set the maximum sub-workflow nesting depth.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use ironflow_engine::guard::WorkflowGuardConfig;
127    ///
128    /// let config = WorkflowGuardConfig::new().with_max_depth(3);
129    /// assert_eq!(config.max_depth, 3);
130    /// ```
131    pub fn with_max_depth(mut self, max_depth: u32) -> Self {
132        self.max_depth = max_depth;
133        self
134    }
135
136    /// Set the maximum total workflow invocations.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use ironflow_engine::guard::WorkflowGuardConfig;
142    ///
143    /// let config = WorkflowGuardConfig::new().with_max_fan_out(10);
144    /// assert_eq!(config.max_fan_out, 10);
145    /// ```
146    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    /// Set the cumulative token budget.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use ironflow_engine::guard::WorkflowGuardConfig;
157    ///
158    /// let config = WorkflowGuardConfig::new().with_max_workflow_tokens(50_000);
159    /// assert_eq!(config.max_workflow_tokens, 50_000);
160    /// ```
161    pub fn with_max_workflow_tokens(mut self, max: u64) -> Self {
162        self.max_workflow_tokens = max;
163        self
164    }
165
166    /// Set the global timeout in seconds.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use ironflow_engine::guard::WorkflowGuardConfig;
172    ///
173    /// let config = WorkflowGuardConfig::new().with_workflow_timeout_secs(60);
174    /// assert_eq!(config.workflow_timeout_secs, 60);
175    /// ```
176    pub fn with_workflow_timeout_secs(mut self, secs: u64) -> Self {
177        self.workflow_timeout_secs = secs;
178        self
179    }
180
181    /// Set the absolute deadline of a run, counted from its creation.
182    ///
183    /// Unlike the timeout, the deadline includes the time the run spends
184    /// sleeping (waiting for an approval, a signal or a timer): a run still
185    /// alive `deadline` after its creation is cancelled.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use std::time::Duration;
191    ///
192    /// use ironflow_engine::guard::WorkflowGuardConfig;
193    ///
194    /// let week = Duration::from_secs(7 * 24 * 3600);
195    /// let config = WorkflowGuardConfig::new().with_run_deadline(week);
196    /// assert_eq!(config.run_deadline, Some(week));
197    /// ```
198    pub fn with_run_deadline(mut self, deadline: Duration) -> Self {
199        self.run_deadline = Some(deadline);
200        self
201    }
202
203    /// The instant a run created at `created_at` reaches its deadline.
204    ///
205    /// `None` without [`run_deadline`](Self::run_deadline), or when the
206    /// deadline cannot be represented as a date.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use std::time::Duration;
212    ///
213    /// use chrono::{TimeDelta, Utc};
214    /// use ironflow_engine::guard::WorkflowGuardConfig;
215    ///
216    /// let created_at = Utc::now();
217    /// let config = WorkflowGuardConfig::new().with_run_deadline(Duration::from_secs(60));
218    /// assert_eq!(
219    ///     config.deadline_for(created_at),
220    ///     Some(created_at + TimeDelta::seconds(60))
221    /// );
222    /// assert_eq!(WorkflowGuardConfig::new().deadline_for(created_at), None);
223    /// ```
224    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/// Reason a workflow invocation was rejected by the guard.
239///
240/// Each variant carries the observed value and the configured limit
241/// for diagnostics.
242///
243/// # Examples
244///
245/// ```
246/// use ironflow_engine::guard::WorkflowRejection;
247///
248/// let r = WorkflowRejection::MaxDepthExceeded { depth: 6, max: 5 };
249/// assert!(r.to_string().contains("6"));
250/// assert!(r.to_string().contains("5"));
251/// ```
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum WorkflowRejection {
254    /// The sub-workflow nesting depth exceeds the configured maximum.
255    MaxDepthExceeded {
256        /// Current depth.
257        depth: u32,
258        /// Configured limit.
259        max: u32,
260    },
261    /// The target workflow is already in the call chain (direct or indirect cycle).
262    CycleDetected {
263        /// The workflow that would create a cycle.
264        target: String,
265        /// The current call chain.
266        chain: Vec<String>,
267    },
268    /// The total number of workflow invocations exceeds the configured maximum.
269    MaxFanOutExceeded {
270        /// Current invocation count.
271        invocations: u32,
272        /// Configured limit.
273        max: u32,
274    },
275    /// The cumulative token usage exceeds the configured budget.
276    TokenBudgetExhausted {
277        /// Tokens already consumed.
278        used: u64,
279        /// Configured limit.
280        max: u64,
281    },
282    /// The workflow has exceeded its global timeout.
283    WorkflowTimeout {
284        /// Seconds elapsed since the workflow started.
285        elapsed_secs: u64,
286        /// Configured limit.
287        max: u64,
288    },
289    /// The run reached the absolute deadline set by
290    /// [`WorkflowGuardConfig::run_deadline`].
291    RunDeadlineReached {
292        /// When the deadline was reached.
293        deadline_at: DateTime<Utc>,
294    },
295    /// The guard state is unavailable (fail closed).
296    GuardUnavailable,
297}
298
299/// Business error code for guard rejections.
300pub 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/// Mutable runtime state tracked by the guard during a workflow execution.
336///
337/// Shared between parent and child workflows via `Arc<Mutex<_>>` so that
338/// limits are enforced globally across the entire run tree.
339///
340/// # Examples
341///
342/// ```
343/// use ironflow_engine::guard::{WorkflowGuardConfig, WorkflowGuardState};
344///
345/// let mut state = WorkflowGuardState::new();
346/// let config = WorkflowGuardConfig::new().with_max_depth(2);
347///
348/// state.record_invocation("workflow-a");
349/// state.record_invocation("workflow-b");
350/// assert!(state.check(&config, "workflow-c").is_err());
351/// ```
352#[derive(Debug)]
353pub struct WorkflowGuardState {
354    /// Current sub-workflow nesting depth.
355    depth: u32,
356    /// Call chain for cycle detection (stack of workflow names).
357    call_chain: Vec<String>,
358    /// Counters of the whole run tree, shared with every branch.
359    totals: Arc<GuardTotals>,
360    /// When the current execution of the root workflow started.
361    started_at: Instant,
362    /// Active time spent by earlier executions of the run.
363    carried_elapsed: Duration,
364    /// Absolute deadline of the run, if any.
365    deadline_at: Option<DateTime<Utc>>,
366}
367
368/// Counters a run tree shares across the branches of its concurrent
369/// sub-workflows.
370#[derive(Debug, Default)]
371struct GuardTotals {
372    /// Total number of workflow invocations in this run tree.
373    invocations: AtomicU32,
374    /// Cumulative tokens consumed across all agent steps.
375    tokens_used: AtomicU64,
376}
377
378impl WorkflowGuardState {
379    /// Create a fresh guard state for a new workflow execution.
380    ///
381    /// # Examples
382    ///
383    /// ```
384    /// use ironflow_engine::guard::WorkflowGuardState;
385    ///
386    /// let state = WorkflowGuardState::new();
387    /// assert_eq!(state.depth(), 0);
388    /// assert_eq!(state.total_invocations(), 0);
389    /// ```
390    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    /// Seed the tokens already used by earlier executions of the run tree.
402    ///
403    /// Call it before the state is shared.
404    ///
405    /// # Examples
406    ///
407    /// ```
408    /// use ironflow_engine::guard::WorkflowGuardState;
409    ///
410    /// let state = WorkflowGuardState::new().with_tokens_used(700);
411    /// assert_eq!(state.total_tokens_used(), 700);
412    /// ```
413    pub fn with_tokens_used(self, tokens: u64) -> Self {
414        self.totals.tokens_used.store(tokens, Ordering::Relaxed);
415        self
416    }
417
418    /// Seed the active time spent by earlier executions of the run.
419    ///
420    /// # Examples
421    ///
422    /// ```
423    /// use std::time::Duration;
424    ///
425    /// use ironflow_engine::guard::WorkflowGuardState;
426    ///
427    /// let state = WorkflowGuardState::new().with_elapsed(Duration::from_secs(30));
428    /// assert!(state.elapsed_secs() >= 30);
429    /// ```
430    pub fn with_elapsed(mut self, elapsed: Duration) -> Self {
431        self.carried_elapsed = elapsed;
432        self
433    }
434
435    /// Set the absolute deadline of the run.
436    ///
437    /// # Examples
438    ///
439    /// ```
440    /// use chrono::Utc;
441    /// use ironflow_engine::guard::WorkflowGuardState;
442    ///
443    /// let at = Utc::now();
444    /// let state = WorkflowGuardState::new().with_deadline_at(at);
445    /// assert_eq!(state.deadline_at(), Some(at));
446    /// ```
447    pub fn with_deadline_at(mut self, deadline_at: DateTime<Utc>) -> Self {
448        self.deadline_at = Some(deadline_at);
449        self
450    }
451
452    /// The absolute deadline of the run, if any.
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use ironflow_engine::guard::WorkflowGuardState;
458    ///
459    /// assert_eq!(WorkflowGuardState::new().deadline_at(), None);
460    /// ```
461    pub fn deadline_at(&self) -> Option<DateTime<Utc>> {
462        self.deadline_at
463    }
464
465    /// The state of a `target_workflow` child running concurrently with its
466    /// siblings, its invocation already claimed by
467    /// [`enter_wave`](Self::enter_wave).
468    ///
469    /// The branch is one level below this state, `target_workflow` on top of
470    /// its call chain, and keeps its own depth and chain from then on, so
471    /// siblings never push to, or pop from, each other's chain. Invocations,
472    /// tokens and the start time stay shared with the whole run tree.
473    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    /// Current sub-workflow nesting depth.
487    pub fn depth(&self) -> u32 {
488        self.depth
489    }
490
491    /// Total workflow invocations so far.
492    pub fn total_invocations(&self) -> u32 {
493        self.totals.invocations.load(Ordering::Relaxed)
494    }
495
496    /// Total tokens consumed so far.
497    pub fn total_tokens_used(&self) -> u64 {
498        self.totals.tokens_used.load(Ordering::Relaxed)
499    }
500
501    /// The current call chain (workflow name stack).
502    pub fn call_chain(&self) -> &[String] {
503        &self.call_chain
504    }
505
506    /// Seconds of active execution time across every execution of the run,
507    /// sleeping excluded.
508    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    /// Check the workflow timeout, then the run deadline.
518    ///
519    /// # Errors
520    ///
521    /// Returns [`WorkflowRejection::WorkflowTimeout`] once the active time
522    /// reaches the timeout, [`WorkflowRejection::RunDeadlineReached`] once
523    /// the deadline is past.
524    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    /// The time left before the nearest time limit (timeout or deadline),
541    /// and the rejection to raise when it runs out.
542    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        // A negative delta means the deadline is already past.
555        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    /// Read-only check: would invoking `target_workflow` violate any limit?
565    ///
566    /// Does not mutate state. Call this before
567    /// [`record_invocation`](Self::record_invocation).
568    ///
569    /// # Errors
570    ///
571    /// Returns the specific [`WorkflowRejection`] variant that would be
572    /// violated.
573    ///
574    /// # Examples
575    ///
576    /// ```
577    /// use ironflow_engine::guard::{WorkflowGuardConfig, WorkflowGuardState, WorkflowRejection};
578    ///
579    /// let state = WorkflowGuardState::new();
580    /// let config = WorkflowGuardConfig::new().with_max_depth(0);
581    ///
582    /// let err = state.check(&config, "child").unwrap_err();
583    /// assert!(matches!(err, WorkflowRejection::MaxDepthExceeded { .. }));
584    /// ```
585    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    /// [`check`](Self::check) `target_workflow`, then claim its invocation
624    /// and record it like [`record_invocation`](Self::record_invocation).
625    ///
626    /// The fan-out slot is claimed atomically: branches running concurrently
627    /// never cross `max_fan_out` together.
628    ///
629    /// # Errors
630    ///
631    /// Returns the [`WorkflowRejection`] the invocation would violate; nothing
632    /// is recorded then.
633    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    /// [`check`](Self::check) a wave of `count` concurrent invocations of
645    /// `target_workflow`, then claim all of them at once. Each child then
646    /// runs on its own [`branch_into`](Self::branch_into).
647    ///
648    /// # Errors
649    ///
650    /// Returns the [`WorkflowRejection`] the wave would violate; a wave that
651    /// does not fit whole is refused whole and claims nothing.
652    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    /// Record that a sub-workflow invocation is starting.
674    ///
675    /// Increments depth and fan-out counter, and pushes the workflow name
676    /// onto the call chain for cycle detection.
677    ///
678    /// # Examples
679    ///
680    /// ```
681    /// use ironflow_engine::guard::WorkflowGuardState;
682    ///
683    /// let mut state = WorkflowGuardState::new();
684    /// state.record_invocation("child-workflow");
685    /// assert_eq!(state.depth(), 1);
686    /// assert_eq!(state.total_invocations(), 1);
687    /// assert_eq!(state.call_chain(), &["child-workflow"]);
688    /// ```
689    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    /// Record that a sub-workflow invocation has returned.
696    ///
697    /// Decrements depth and pops the last entry from the call chain.
698    /// Safe to call even on failure paths (the guard must never leak depth).
699    ///
700    /// # Examples
701    ///
702    /// ```
703    /// use ironflow_engine::guard::WorkflowGuardState;
704    ///
705    /// let mut state = WorkflowGuardState::new();
706    /// state.record_invocation("child");
707    /// assert_eq!(state.depth(), 1);
708    ///
709    /// state.record_return();
710    /// assert_eq!(state.depth(), 0);
711    /// assert!(state.call_chain().is_empty());
712    /// ```
713    pub fn record_return(&mut self) {
714        self.depth = self.depth.saturating_sub(1);
715        self.call_chain.pop();
716    }
717
718    /// Record tokens consumed by an agent step.
719    ///
720    /// Returns the remaining token budget, or an error if the budget is
721    /// now exhausted.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`WorkflowRejection::TokenBudgetExhausted`] when the
726    /// cumulative usage exceeds the configured maximum.
727    ///
728    /// # Examples
729    ///
730    /// ```
731    /// use ironflow_engine::guard::{WorkflowGuardConfig, WorkflowGuardState, WorkflowRejection};
732    ///
733    /// let mut state = WorkflowGuardState::new();
734    /// let config = WorkflowGuardConfig::new().with_max_workflow_tokens(100);
735    ///
736    /// assert!(state.record_tokens(&config, 50).is_ok());
737    /// assert!(state.record_tokens(&config, 60).is_err());
738    /// ```
739    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
765/// Thread-safe handle to a shared [`WorkflowGuardState`].
766///
767/// Passed from parent to child workflows so that limits are enforced
768/// globally across the entire run tree.
769pub type SharedGuardState = Arc<Mutex<WorkflowGuardState>>;
770
771/// Create a new shared guard state for a workflow run.
772///
773/// # Examples
774///
775/// ```
776/// use ironflow_engine::guard::new_shared_guard_state;
777///
778/// let state = new_shared_guard_state();
779/// let locked = state.lock().unwrap();
780/// assert_eq!(locked.depth(), 0);
781/// ```
782pub 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        // SAFETY: test-only, single-threaded access to env vars.
808        // Combined into one test to avoid races between parallel tests.
809        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        // SAFETY: test-only cleanup.
823        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        // elapsed().as_secs() truncates to whole seconds, so we use a state
1085        // whose started_at we can set to the past via the test-only constructor.
1086        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}