aivcs_core/hitl_controls/
checkpoint.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::risk::RiskTier;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ApprovalCheckpoint {
15 pub checkpoint_id: String,
17 pub label: String,
19 pub run_id: Uuid,
21 pub risk_tier: RiskTier,
23 pub created_at: DateTime<Utc>,
25 pub expires_at: Option<DateTime<Utc>>,
27 pub status: CheckpointStatus,
29 pub explanation: ExplainabilitySummary,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum CheckpointStatus {
37 Pending,
39 Approved,
41 Rejected { reason: String },
43 Expired,
45 Paused,
47}
48
49impl CheckpointStatus {
50 pub fn allows_proceed(&self) -> bool {
52 matches!(self, Self::Approved)
53 }
54
55 pub fn is_terminal(&self) -> bool {
57 matches!(self, Self::Approved | Self::Rejected { .. } | Self::Expired)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ExplainabilitySummary {
66 pub action_description: String,
68 pub changes_summary: String,
70 pub flag_reason: String,
72}
73
74impl ApprovalCheckpoint {
75 pub fn new(
77 label: impl Into<String>,
78 run_id: Uuid,
79 risk_tier: RiskTier,
80 explanation: ExplainabilitySummary,
81 timeout_secs: Option<u64>,
82 now: DateTime<Utc>,
83 ) -> Self {
84 let expires_at = timeout_secs.map(|s| now + chrono::Duration::seconds(s as i64));
85 Self {
86 checkpoint_id: Uuid::new_v4().to_string(),
87 label: label.into(),
88 run_id,
89 risk_tier,
90 created_at: now,
91 expires_at,
92 status: CheckpointStatus::Pending,
93 explanation,
94 }
95 }
96
97 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
99 self.expires_at.is_some_and(|exp| now >= exp)
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn sample_explanation() -> ExplainabilitySummary {
108 ExplainabilitySummary {
109 action_description: "deploy to production".into(),
110 changes_summary: "bumps API version from v2 to v3".into(),
111 flag_reason: "production deploy is high-risk".into(),
112 }
113 }
114
115 #[test]
116 fn test_new_checkpoint_is_pending() {
117 let cp = ApprovalCheckpoint::new(
118 "deploy-prod",
119 Uuid::new_v4(),
120 RiskTier::High,
121 sample_explanation(),
122 Some(300),
123 Utc::now(),
124 );
125 assert_eq!(cp.status, CheckpointStatus::Pending);
126 assert!(cp.expires_at.is_some());
127 }
128
129 #[test]
130 fn test_checkpoint_status_allows_proceed() {
131 assert!(CheckpointStatus::Approved.allows_proceed());
132 assert!(!CheckpointStatus::Pending.allows_proceed());
133 assert!(!CheckpointStatus::Expired.allows_proceed());
134 assert!(!CheckpointStatus::Rejected {
135 reason: "no".into()
136 }
137 .allows_proceed());
138 }
139
140 #[test]
141 fn test_checkpoint_status_is_terminal() {
142 assert!(!CheckpointStatus::Pending.is_terminal());
143 assert!(!CheckpointStatus::Paused.is_terminal());
144 assert!(CheckpointStatus::Approved.is_terminal());
145 assert!(CheckpointStatus::Expired.is_terminal());
146 assert!(CheckpointStatus::Rejected { reason: "x".into() }.is_terminal());
147 }
148
149 #[test]
150 fn test_is_expired_at() {
151 let now = Utc::now();
152 let cp = ApprovalCheckpoint::new(
153 "test",
154 Uuid::new_v4(),
155 RiskTier::High,
156 sample_explanation(),
157 Some(60),
158 now,
159 );
160 assert!(!cp.is_expired_at(now));
161 assert!(cp.is_expired_at(now + chrono::Duration::seconds(61)));
162 }
163
164 #[test]
165 fn test_no_expiry_never_expires() {
166 let cp = ApprovalCheckpoint::new(
167 "test",
168 Uuid::new_v4(),
169 RiskTier::Critical,
170 sample_explanation(),
171 None,
172 Utc::now(),
173 );
174 assert!(!cp.is_expired_at(Utc::now() + chrono::Duration::days(365)));
175 }
176
177 #[test]
178 fn test_serde_roundtrip() {
179 let cp = ApprovalCheckpoint::new(
180 "deploy-staging",
181 Uuid::new_v4(),
182 RiskTier::High,
183 sample_explanation(),
184 Some(120),
185 Utc::now(),
186 );
187 let json = serde_json::to_string(&cp).unwrap();
188 let back: ApprovalCheckpoint = serde_json::from_str(&json).unwrap();
189 assert_eq!(cp, back);
190 }
191}