Skip to main content

agentstategraph_core/
lifecycle.rs

1//! Intent lifecycle state machine — enforces valid transitions.
2//!
3//! Proposed → Authorized → InProgress → Completed
4//!                │                │ → Failed
5//!                │                └─→ Blocked → InProgress
6//!                └─→ (rejected)                     → Failed
7
8use crate::intent::{IntentLifecycle, IntentStatus, Resolution};
9
10/// Errors from lifecycle transitions.
11#[derive(Debug, Clone, PartialEq, thiserror::Error)]
12pub enum LifecycleError {
13    #[error("invalid transition from {from:?} to {to:?}")]
14    InvalidTransition {
15        from: IntentStatus,
16        to: IntentStatus,
17    },
18    #[error("resolution required to transition to {0:?}")]
19    ResolutionRequired(IntentStatus),
20}
21
22/// Check if a lifecycle transition is valid.
23pub fn is_valid_transition(from: &IntentStatus, to: &IntentStatus) -> bool {
24    matches!(
25        (from, to),
26        (IntentStatus::Proposed, IntentStatus::Authorized)
27            | (IntentStatus::Proposed, IntentStatus::InProgress) // skip auth for simple cases
28            | (IntentStatus::Authorized, IntentStatus::InProgress)
29            | (IntentStatus::InProgress, IntentStatus::Completed)
30            | (IntentStatus::InProgress, IntentStatus::Failed)
31            | (IntentStatus::InProgress, IntentStatus::Blocked)
32            | (IntentStatus::Blocked, IntentStatus::InProgress)
33            | (IntentStatus::Blocked, IntentStatus::Failed)
34    )
35}
36
37/// Transition an intent lifecycle to a new status.
38/// Validates the transition and enforces requirements (e.g., resolution for terminal states).
39pub fn transition(
40    lifecycle: &mut IntentLifecycle,
41    to: IntentStatus,
42    resolution: Option<Resolution>,
43) -> Result<(), LifecycleError> {
44    if !is_valid_transition(&lifecycle.status, &to) {
45        return Err(LifecycleError::InvalidTransition {
46            from: lifecycle.status.clone(),
47            to,
48        });
49    }
50
51    // Terminal states require a resolution
52    match &to {
53        IntentStatus::Completed | IntentStatus::Failed => {
54            if resolution.is_none() && lifecycle.resolution.is_none() {
55                return Err(LifecycleError::ResolutionRequired(to));
56            }
57            if let Some(res) = resolution {
58                lifecycle.resolution = Some(res);
59            }
60        }
61        _ => {}
62    }
63
64    lifecycle.status = to;
65    Ok(())
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::intent::{Outcome, Resolution};
72
73    fn test_lifecycle() -> IntentLifecycle {
74        IntentLifecycle {
75            status: IntentStatus::Proposed,
76            assigned_to: vec![],
77            resolution: None,
78            notification: None,
79        }
80    }
81
82    fn test_resolution() -> Resolution {
83        Resolution {
84            summary: "Done".to_string(),
85            deviations: vec![],
86            commits: vec![],
87            branches_explored: vec![],
88            outcome: Outcome::Fulfilled,
89            confidence: 0.9,
90        }
91    }
92
93    #[test]
94    fn test_valid_happy_path() {
95        let mut lc = test_lifecycle();
96        assert!(transition(&mut lc, IntentStatus::Authorized, None).is_ok());
97        assert!(transition(&mut lc, IntentStatus::InProgress, None).is_ok());
98        assert!(transition(&mut lc, IntentStatus::Completed, Some(test_resolution())).is_ok());
99        assert_eq!(lc.status, IntentStatus::Completed);
100        assert!(lc.resolution.is_some());
101    }
102
103    #[test]
104    fn test_skip_auth() {
105        let mut lc = test_lifecycle();
106        assert!(transition(&mut lc, IntentStatus::InProgress, None).is_ok());
107    }
108
109    #[test]
110    fn test_blocked_and_resume() {
111        let mut lc = test_lifecycle();
112        transition(&mut lc, IntentStatus::InProgress, None).unwrap();
113        transition(&mut lc, IntentStatus::Blocked, None).unwrap();
114        transition(&mut lc, IntentStatus::InProgress, None).unwrap();
115        assert_eq!(lc.status, IntentStatus::InProgress);
116    }
117
118    #[test]
119    fn test_invalid_transition() {
120        let mut lc = test_lifecycle();
121        assert!(transition(&mut lc, IntentStatus::Completed, Some(test_resolution())).is_err());
122    }
123
124    #[test]
125    fn test_completed_requires_resolution() {
126        let mut lc = test_lifecycle();
127        transition(&mut lc, IntentStatus::InProgress, None).unwrap();
128        assert!(transition(&mut lc, IntentStatus::Completed, None).is_err());
129    }
130
131    #[test]
132    fn test_failed_requires_resolution() {
133        let mut lc = test_lifecycle();
134        transition(&mut lc, IntentStatus::InProgress, None).unwrap();
135        assert!(transition(&mut lc, IntentStatus::Failed, None).is_err());
136    }
137
138    #[test]
139    fn test_cannot_go_backwards() {
140        let mut lc = test_lifecycle();
141        transition(&mut lc, IntentStatus::Authorized, None).unwrap();
142        transition(&mut lc, IntentStatus::InProgress, None).unwrap();
143        assert!(transition(&mut lc, IntentStatus::Proposed, None).is_err());
144        assert!(transition(&mut lc, IntentStatus::Authorized, None).is_err());
145    }
146}