use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::principal::PrincipalSummary;
use crate::typed_id::{PrincipalId, ScheduleId, SessionId};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
pub const MAX_ACTIVE_SCHEDULES_PER_SESSION: u32 = 5;
pub const DEFAULT_MIN_INTERVAL_SECONDS: i64 = 300;
pub const DEFAULT_MAX_SCHEDULES_PER_ORG: i64 = 100;
pub fn min_interval_seconds() -> i64 {
std::env::var("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_MIN_INTERVAL_SECONDS)
}
pub fn max_active_schedules_per_org() -> i64 {
std::env::var("RESOURCE_LIMIT_MAX_SESSION_SCHEDULES_PER_ORG")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|&v| v > 0)
.unwrap_or(DEFAULT_MAX_SCHEDULES_PER_ORG)
}
pub fn cron_min_interval_seconds(cron_expression: &str) -> Option<i64> {
use std::str::FromStr;
let fields: Vec<&str> = cron_expression.split_whitespace().collect();
let normalized = match fields.len() {
5 => format!("0 {} *", fields.join(" ")),
6 | 7 => cron_expression.to_string(),
_ => return None,
};
let schedule = cron::Schedule::from_str(&normalized).ok()?;
let upcoming: Vec<_> = schedule.upcoming(chrono::Utc).take(3).collect();
if upcoming.len() < 2 {
return None;
}
upcoming
.windows(2)
.map(|w| (w[1] - w[0]).num_seconds())
.min()
}
pub fn validate_cron_min_interval(cron_expression: &str) -> Result<(), String> {
let min_limit = min_interval_seconds();
if let Some(interval) = cron_min_interval_seconds(cron_expression)
&& interval < min_limit
{
return Err(format!(
"Schedule cron must fire no more than once every {min_limit} seconds (≥ {} min); expression fires every {interval} seconds",
min_limit / 60
));
}
Ok(())
}
pub enum ScheduleLimitError {
Store(crate::error::AgentLoopError),
Rejected(String),
}
pub async fn validate_schedule_create_limits<T: crate::traits::SessionScheduleStore + ?Sized>(
store: &T,
session_id: SessionId,
cron_expression: Option<&str>,
) -> std::result::Result<(), ScheduleLimitError> {
let per_session = store
.count_active_schedules(session_id)
.await
.map_err(ScheduleLimitError::Store)?;
if per_session >= MAX_ACTIVE_SCHEDULES_PER_SESSION {
return Err(ScheduleLimitError::Rejected(format!(
"Maximum {MAX_ACTIVE_SCHEDULES_PER_SESSION} active schedules per session. Cancel an existing schedule first."
)));
}
let max_per_org = max_active_schedules_per_org();
let per_org = store
.count_active_org_schedules()
.await
.map_err(ScheduleLimitError::Store)?;
if i64::from(per_org) >= max_per_org {
return Err(ScheduleLimitError::Rejected(format!(
"Maximum {max_per_org} active schedules per org reached. Cancel an existing schedule first."
)));
}
if let Some(cron) = cron_expression {
validate_cron_min_interval(cron).map_err(ScheduleLimitError::Rejected)?;
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum ScheduleType {
OneShot,
Recurring,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SessionSchedule {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "sched_01933b5a00007000800000000000001"))]
pub id: ScheduleId,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
pub session_id: SessionId,
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
pub owner_principal_id: PrincipalId,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_owner_user_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<PrincipalSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_owner: Option<PrincipalSummary>,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cron_expression: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheduled_at: Option<DateTime<Utc>>,
pub timezone: String,
pub enabled: bool,
pub schedule_type: ScheduleType,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_trigger_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_triggered_at: Option<DateTime<Utc>>,
pub trigger_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl SessionSchedule {
pub fn derive_type(cron_expression: &Option<String>) -> ScheduleType {
if cron_expression.is_some() {
ScheduleType::Recurring
} else {
ScheduleType::OneShot
}
}
}
#[cfg(test)]
pub(crate) struct EnvVarGuard {
key: &'static str,
prev: Option<String>,
}
#[cfg(test)]
impl EnvVarGuard {
pub(crate) fn unset(key: &'static str) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
#[cfg(test)]
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn min_interval_default_is_300() {
let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
assert_eq!(min_interval_seconds(), DEFAULT_MIN_INTERVAL_SECONDS);
}
#[test]
fn cron_interval_every_minute_is_60() {
assert_eq!(cron_min_interval_seconds("* * * * *"), Some(60));
}
#[test]
fn cron_interval_every_5_min_is_300() {
assert_eq!(cron_min_interval_seconds("*/5 * * * *"), Some(300));
}
#[test]
fn cron_interval_six_field_every_30s() {
assert_eq!(cron_min_interval_seconds("*/30 * * * * *"), Some(30));
}
#[test]
fn cron_interval_unparseable_is_none() {
assert_eq!(cron_min_interval_seconds("not a cron"), None);
assert_eq!(cron_min_interval_seconds("* * *"), None);
}
#[test]
fn validate_rejects_every_minute_at_default() {
let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
assert!(validate_cron_min_interval("* * * * *").is_err());
}
#[test]
fn validate_accepts_daily() {
let _g = EnvVarGuard::unset("SESSION_SCHEDULE_MIN_INTERVAL_SECONDS");
assert!(validate_cron_min_interval("0 3 * * *").is_ok());
}
#[test]
fn validate_accepts_unparseable() {
assert!(validate_cron_min_interval("garbage").is_ok());
}
}