1use 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 cron_min_interval_seconds(cron_expression: &str) -> Option<i64> {
35 use std::str::FromStr;
36 let fields: Vec<&str> = cron_expression.split_whitespace().collect();
37 let normalized = match fields.len() {
38 5 => format!("0 {} *", fields.join(" ")),
39 6 | 7 => cron_expression.to_string(),
40 _ => return None,
41 };
42 let schedule = cron::Schedule::from_str(&normalized).ok()?;
43 let upcoming: Vec<_> = schedule.upcoming(chrono::Utc).take(3).collect();
44 if upcoming.len() < 2 {
45 return None;
46 }
47 upcoming
48 .windows(2)
49 .map(|w| (w[1] - w[0]).num_seconds())
50 .min()
51}
52
53pub fn validate_cron_min_interval(cron_expression: &str) -> Result<(), String> {
60 validate_cron_min_interval_with(cron_expression, DEFAULT_MIN_INTERVAL_SECONDS)
61}
62
63pub fn validate_cron_min_interval_with(
65 cron_expression: &str,
66 min_limit: i64,
67) -> Result<(), String> {
68 if let Some(interval) = cron_min_interval_seconds(cron_expression)
69 && interval < min_limit
70 {
71 return Err(format!(
72 "Schedule cron must fire no more than once every {min_limit} seconds (≥ {} min); expression fires every {interval} seconds",
73 min_limit / 60
74 ));
75 }
76 Ok(())
77}
78
79pub enum ScheduleLimitError {
85 Store(crate::error::AgentLoopError),
87 Rejected(String),
89}
90
91pub async fn validate_schedule_create_limits<
99 T: crate::session_services::SessionScheduleStore + ?Sized,
100>(
101 store: &T,
102 session_id: SessionId,
103 cron_expression: Option<&str>,
104) -> std::result::Result<(), ScheduleLimitError> {
105 let per_session = store
106 .count_active_schedules(session_id)
107 .await
108 .map_err(ScheduleLimitError::Store)?;
109 if per_session >= MAX_ACTIVE_SCHEDULES_PER_SESSION {
110 return Err(ScheduleLimitError::Rejected(format!(
111 "Maximum {MAX_ACTIVE_SCHEDULES_PER_SESSION} active schedules per session. Cancel an existing schedule first."
112 )));
113 }
114
115 let max_per_org = DEFAULT_MAX_SCHEDULES_PER_ORG;
116 let per_org = store
117 .count_active_org_schedules()
118 .await
119 .map_err(ScheduleLimitError::Store)?;
120 if i64::from(per_org) >= max_per_org {
121 return Err(ScheduleLimitError::Rejected(format!(
122 "Maximum {max_per_org} active schedules per org reached. Cancel an existing schedule first."
123 )));
124 }
125
126 if let Some(cron) = cron_expression {
127 validate_cron_min_interval(cron).map_err(ScheduleLimitError::Rejected)?;
128 }
129
130 Ok(())
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[cfg_attr(feature = "openapi", derive(ToSchema))]
136#[serde(rename_all = "lowercase")]
137pub enum ScheduleType {
138 OneShot,
140 Recurring,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(ToSchema))]
147pub struct SessionSchedule {
148 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "sched_01933b5a00007000800000000000001"))]
150 pub id: ScheduleId,
151 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
153 pub session_id: SessionId,
154 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
156 pub owner_principal_id: PrincipalId,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub resolved_owner_user_id: Option<Uuid>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub owner: Option<PrincipalSummary>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub effective_owner: Option<PrincipalSummary>,
166 pub description: String,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub cron_expression: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub scheduled_at: Option<DateTime<Utc>>,
174 pub timezone: String,
176 pub enabled: bool,
178 pub schedule_type: ScheduleType,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub next_trigger_at: Option<DateTime<Utc>>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub last_triggered_at: Option<DateTime<Utc>>,
186 pub trigger_count: u32,
188 pub created_at: DateTime<Utc>,
189 pub updated_at: DateTime<Utc>,
190}
191
192impl SessionSchedule {
193 pub fn derive_type(cron_expression: &Option<String>) -> ScheduleType {
195 if cron_expression.is_some() {
196 ScheduleType::Recurring
197 } else {
198 ScheduleType::OneShot
199 }
200 }
201}
202
203#[cfg(test)]
204mod limit_tests {
205 use super::*;
206 use crate::error::{AgentLoopError, Result};
207 use crate::session_services::SessionScheduleStore;
208
209 #[test]
210 fn cron_forms_preserve_literal_intervals() {
211 for (expression, seconds) in [
212 ("* * * * *", 60),
213 ("*/5 * * * *", 300),
214 ("*/30 * * * * *", 30),
215 ("0 */5 * * * * *", 300),
216 (" */5 * * * * ", 300),
217 ("0 3 * * *", 86_400),
218 ] {
219 assert_eq!(
220 cron_min_interval_seconds(expression),
221 Some(seconds),
222 "{expression}"
223 );
224 }
225 }
226
227 #[test]
228 fn unrecognized_or_exhausted_cron_has_no_interval_and_defers_validation() {
229 for expression in [
230 "not a cron",
231 "* * *",
232 "* * * * * * * *",
233 "99 * * * *",
234 "0 0 0 1 1 * 2000",
235 ] {
236 assert_eq!(cron_min_interval_seconds(expression), None, "{expression}");
237 assert_eq!(validate_cron_min_interval(expression), Ok(()));
238 }
239 }
240
241 #[test]
242 fn interval_gate_enforces_default_and_custom_inclusive_boundaries() {
243 assert_eq!(validate_cron_min_interval("* * * * *"), Err("Schedule cron must fire no more than once every 300 seconds (≥ 5 min); expression fires every 60 seconds".into()));
244 assert_eq!(validate_cron_min_interval("*/5 * * * *"), Ok(()));
245 assert_eq!(validate_cron_min_interval("0 3 * * *"), Ok(()));
246 assert_eq!(validate_cron_min_interval_with("* * * * *", 59), Ok(()));
247 assert_eq!(validate_cron_min_interval_with("* * * * *", 60), Ok(()));
248 assert_eq!(validate_cron_min_interval_with("* * * * *", 61), Err("Schedule cron must fire no more than once every 61 seconds (≥ 1 min); expression fires every 60 seconds".into()));
249 }
250
251 struct Counts {
252 session: std::result::Result<u32, &'static str>,
253 org: std::result::Result<u32, &'static str>,
254 }
255
256 #[async_trait::async_trait]
257 impl SessionScheduleStore for Counts {
258 async fn create_schedule(
259 &self,
260 _: SessionId,
261 _: String,
262 _: Option<String>,
263 _: Option<DateTime<Utc>>,
264 _: String,
265 ) -> Result<SessionSchedule> {
266 panic!("validation must not create schedules")
267 }
268 async fn cancel_schedule(&self, _: SessionId, _: ScheduleId) -> Result<SessionSchedule> {
269 panic!("validation must not cancel schedules")
270 }
271 async fn list_schedules(&self, _: SessionId) -> Result<Vec<SessionSchedule>> {
272 panic!("validation must use counts rather than fetching schedules")
273 }
274 async fn count_active_schedules(&self, session_id: SessionId) -> Result<u32> {
275 assert_eq!(session_id, SessionId::from_seed(9));
276 self.session.map_err(AgentLoopError::store)
277 }
278 async fn count_active_org_schedules(&self) -> Result<u32> {
279 self.org.map_err(AgentLoopError::store)
280 }
281 }
282
283 #[tokio::test]
284 async fn create_limits_enforce_independent_session_org_and_cron_caps() {
285 let session = SessionId::from_seed(9);
286 for (per_session, per_org, cron, expected) in [
287 (4, 99, None, None),
288 (4, 99, Some("*/5 * * * *"), None),
289 (
290 5,
291 0,
292 None,
293 Some("Maximum 5 active schedules per session. Cancel an existing schedule first."),
294 ),
295 (
296 6,
297 100,
298 None,
299 Some("Maximum 5 active schedules per session. Cancel an existing schedule first."),
300 ),
301 (
302 0,
303 100,
304 None,
305 Some(
306 "Maximum 100 active schedules per org reached. Cancel an existing schedule first.",
307 ),
308 ),
309 (
310 0,
311 101,
312 None,
313 Some(
314 "Maximum 100 active schedules per org reached. Cancel an existing schedule first.",
315 ),
316 ),
317 (
318 0,
319 0,
320 Some("* * * * *"),
321 Some(
322 "Schedule cron must fire no more than once every 300 seconds (≥ 5 min); expression fires every 60 seconds",
323 ),
324 ),
325 ] {
326 let store = Counts {
327 session: Ok(per_session),
328 org: Ok(per_org),
329 };
330 match (
331 validate_schedule_create_limits(&store, session, cron).await,
332 expected,
333 ) {
334 (Ok(()), None) => {}
335 (Err(ScheduleLimitError::Rejected(message)), Some(expected)) => {
336 assert_eq!(message, expected)
337 }
338 _ => panic!("unexpected result for {per_session}/{per_org}/{cron:?}"),
339 }
340 }
341 }
342
343 #[tokio::test]
344 async fn count_failures_remain_store_errors_and_session_rejection_wins() {
345 let session = SessionId::from_seed(9);
346 for store in [
347 Counts {
348 session: Err("session unavailable"),
349 org: Ok(0),
350 },
351 Counts {
352 session: Ok(0),
353 org: Err("org unavailable"),
354 },
355 ] {
356 let expected = store.session.err().or(store.org.err()).unwrap();
357 match validate_schedule_create_limits(&store, session, None).await {
358 Err(ScheduleLimitError::Store(error)) => {
359 assert_eq!(
360 error.to_string(),
361 format!("Message store error: {expected}")
362 )
363 }
364 _ => panic!("count failure must remain a store error"),
365 }
366 }
367 let store = Counts {
368 session: Ok(5),
369 org: Err("must not mask session limit"),
370 };
371 assert!(
372 matches!(validate_schedule_create_limits(&store, session, None).await, Err(ScheduleLimitError::Rejected(message)) if message == "Maximum 5 active schedules per session. Cancel an existing schedule first.")
373 );
374 }
375}