everruns_core/
session_schedule.rs1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use crate::principal::PrincipalSummary;
11use crate::typed_id::{PrincipalId, ScheduleId, SessionId};
12
13#[cfg(feature = "openapi")]
14use utoipa::ToSchema;
15
16pub const MAX_ACTIVE_SCHEDULES_PER_SESSION: u32 = 5;
18
19pub const DEFAULT_MIN_INTERVAL_SECONDS: i64 = 300;
21
22pub const DEFAULT_MAX_SCHEDULES_PER_ORG: i64 = 100;
24
25pub fn min_interval_seconds() -> i64 {
33 std::env::var("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS")
34 .ok()
35 .and_then(|v| v.parse::<i64>().ok())
36 .filter(|&v| v > 0)
37 .unwrap_or(DEFAULT_MIN_INTERVAL_SECONDS)
38}
39
40pub fn max_active_schedules_per_org() -> i64 {
48 std::env::var("RESOURCE_LIMIT_MAX_SESSION_SCHEDULES_PER_ORG")
49 .ok()
50 .and_then(|v| v.parse::<i64>().ok())
51 .filter(|&v| v > 0)
52 .unwrap_or(DEFAULT_MAX_SCHEDULES_PER_ORG)
53}
54
55pub fn cron_min_interval_seconds(cron_expression: &str) -> Option<i64> {
65 use std::str::FromStr;
66 let fields: Vec<&str> = cron_expression.split_whitespace().collect();
67 let normalized = match fields.len() {
68 5 => format!("0 {} *", fields.join(" ")),
69 6 | 7 => cron_expression.to_string(),
70 _ => return None,
71 };
72 let schedule = cron::Schedule::from_str(&normalized).ok()?;
73 let upcoming: Vec<_> = schedule.upcoming(chrono::Utc).take(3).collect();
74 if upcoming.len() < 2 {
75 return None;
76 }
77 upcoming
78 .windows(2)
79 .map(|w| (w[1] - w[0]).num_seconds())
80 .min()
81}
82
83pub fn validate_cron_min_interval(cron_expression: &str) -> Result<(), String> {
90 let min_limit = min_interval_seconds();
91 if let Some(interval) = cron_min_interval_seconds(cron_expression)
92 && interval < min_limit
93 {
94 return Err(format!(
95 "Schedule cron must fire no more than once every {min_limit} seconds (≥ {} min); expression fires every {interval} seconds",
96 min_limit / 60
97 ));
98 }
99 Ok(())
100}
101
102pub enum ScheduleLimitError {
108 Store(crate::error::AgentLoopError),
110 Rejected(String),
112}
113
114pub async fn validate_schedule_create_limits<T: crate::traits::SessionScheduleStore + ?Sized>(
122 store: &T,
123 session_id: SessionId,
124 cron_expression: Option<&str>,
125) -> std::result::Result<(), ScheduleLimitError> {
126 let per_session = store
127 .count_active_schedules(session_id)
128 .await
129 .map_err(ScheduleLimitError::Store)?;
130 if per_session >= MAX_ACTIVE_SCHEDULES_PER_SESSION {
131 return Err(ScheduleLimitError::Rejected(format!(
132 "Maximum {MAX_ACTIVE_SCHEDULES_PER_SESSION} active schedules per session. Cancel an existing schedule first."
133 )));
134 }
135
136 let max_per_org = max_active_schedules_per_org();
137 let per_org = store
138 .count_active_org_schedules()
139 .await
140 .map_err(ScheduleLimitError::Store)?;
141 if i64::from(per_org) >= max_per_org {
142 return Err(ScheduleLimitError::Rejected(format!(
143 "Maximum {max_per_org} active schedules per org reached. Cancel an existing schedule first."
144 )));
145 }
146
147 if let Some(cron) = cron_expression {
148 validate_cron_min_interval(cron).map_err(ScheduleLimitError::Rejected)?;
149 }
150
151 Ok(())
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[cfg_attr(feature = "openapi", derive(ToSchema))]
157#[serde(rename_all = "lowercase")]
158pub enum ScheduleType {
159 OneShot,
161 Recurring,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(ToSchema))]
168pub struct SessionSchedule {
169 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "sched_01933b5a00007000800000000000001"))]
171 pub id: ScheduleId,
172 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
174 pub session_id: SessionId,
175 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
177 pub owner_principal_id: PrincipalId,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub resolved_owner_user_id: Option<Uuid>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub owner: Option<PrincipalSummary>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub effective_owner: Option<PrincipalSummary>,
187 pub description: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub cron_expression: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub scheduled_at: Option<DateTime<Utc>>,
195 pub timezone: String,
197 pub enabled: bool,
199 pub schedule_type: ScheduleType,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub next_trigger_at: Option<DateTime<Utc>>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub last_triggered_at: Option<DateTime<Utc>>,
207 pub trigger_count: u32,
209 pub created_at: DateTime<Utc>,
210 pub updated_at: DateTime<Utc>,
211}
212
213impl SessionSchedule {
214 pub fn derive_type(cron_expression: &Option<String>) -> ScheduleType {
216 if cron_expression.is_some() {
217 ScheduleType::Recurring
218 } else {
219 ScheduleType::OneShot
220 }
221 }
222}
223
224#[cfg(test)]
229pub(crate) struct EnvVarGuard {
230 key: &'static str,
231 prev: Option<String>,
232}
233
234#[cfg(test)]
235impl EnvVarGuard {
236 pub(crate) fn unset(key: &'static str) -> Self {
238 let prev = std::env::var(key).ok();
239 unsafe { std::env::remove_var(key) };
240 Self { key, prev }
241 }
242}
243
244#[cfg(test)]
245impl Drop for EnvVarGuard {
246 fn drop(&mut self) {
247 match &self.prev {
248 Some(v) => unsafe { std::env::set_var(self.key, v) },
249 None => unsafe { std::env::remove_var(self.key) },
250 }
251 }
252}
253
254#[cfg(test)]
255mod limit_tests {
256 use super::*;
257
258 #[test]
259 fn min_interval_default_is_300() {
260 let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
261 assert_eq!(min_interval_seconds(), DEFAULT_MIN_INTERVAL_SECONDS);
262 }
263
264 #[test]
265 fn cron_interval_every_minute_is_60() {
266 assert_eq!(cron_min_interval_seconds("* * * * *"), Some(60));
268 }
269
270 #[test]
271 fn cron_interval_every_5_min_is_300() {
272 assert_eq!(cron_min_interval_seconds("*/5 * * * *"), Some(300));
273 }
274
275 #[test]
276 fn cron_interval_six_field_every_30s() {
277 assert_eq!(cron_min_interval_seconds("*/30 * * * * *"), Some(30));
279 }
280
281 #[test]
282 fn cron_interval_unparseable_is_none() {
283 assert_eq!(cron_min_interval_seconds("not a cron"), None);
284 assert_eq!(cron_min_interval_seconds("* * *"), None);
285 }
286
287 #[test]
288 fn validate_rejects_every_minute_at_default() {
289 let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
290 assert!(validate_cron_min_interval("* * * * *").is_err());
291 }
292
293 #[test]
294 fn validate_accepts_daily() {
295 let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
296 assert!(validate_cron_min_interval("0 3 * * *").is_ok());
297 }
298
299 #[test]
300 fn validate_accepts_unparseable() {
301 assert!(validate_cron_min_interval("garbage").is_ok());
303 }
304}