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 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> {
validate_cron_min_interval_with(cron_expression, DEFAULT_MIN_INTERVAL_SECONDS)
}
pub fn validate_cron_min_interval_with(
cron_expression: &str,
min_limit: i64,
) -> Result<(), String> {
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::session_services::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 = DEFAULT_MAX_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)]
mod limit_tests {
use super::*;
use crate::error::{AgentLoopError, Result};
use crate::session_services::SessionScheduleStore;
#[test]
fn cron_forms_preserve_literal_intervals() {
for (expression, seconds) in [
("* * * * *", 60),
("*/5 * * * *", 300),
("*/30 * * * * *", 30),
("0 */5 * * * * *", 300),
(" */5 * * * * ", 300),
("0 3 * * *", 86_400),
] {
assert_eq!(
cron_min_interval_seconds(expression),
Some(seconds),
"{expression}"
);
}
}
#[test]
fn unrecognized_or_exhausted_cron_has_no_interval_and_defers_validation() {
for expression in [
"not a cron",
"* * *",
"* * * * * * * *",
"99 * * * *",
"0 0 0 1 1 * 2000",
] {
assert_eq!(cron_min_interval_seconds(expression), None, "{expression}");
assert_eq!(validate_cron_min_interval(expression), Ok(()));
}
}
#[test]
fn interval_gate_enforces_default_and_custom_inclusive_boundaries() {
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()));
assert_eq!(validate_cron_min_interval("*/5 * * * *"), Ok(()));
assert_eq!(validate_cron_min_interval("0 3 * * *"), Ok(()));
assert_eq!(validate_cron_min_interval_with("* * * * *", 59), Ok(()));
assert_eq!(validate_cron_min_interval_with("* * * * *", 60), Ok(()));
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()));
}
struct Counts {
session: std::result::Result<u32, &'static str>,
org: std::result::Result<u32, &'static str>,
}
#[async_trait::async_trait]
impl SessionScheduleStore for Counts {
async fn create_schedule(
&self,
_: SessionId,
_: String,
_: Option<String>,
_: Option<DateTime<Utc>>,
_: String,
) -> Result<SessionSchedule> {
panic!("validation must not create schedules")
}
async fn cancel_schedule(&self, _: SessionId, _: ScheduleId) -> Result<SessionSchedule> {
panic!("validation must not cancel schedules")
}
async fn list_schedules(&self, _: SessionId) -> Result<Vec<SessionSchedule>> {
panic!("validation must use counts rather than fetching schedules")
}
async fn count_active_schedules(&self, session_id: SessionId) -> Result<u32> {
assert_eq!(session_id, SessionId::from_seed(9));
self.session.map_err(AgentLoopError::store)
}
async fn count_active_org_schedules(&self) -> Result<u32> {
self.org.map_err(AgentLoopError::store)
}
}
#[tokio::test]
async fn create_limits_enforce_independent_session_org_and_cron_caps() {
let session = SessionId::from_seed(9);
for (per_session, per_org, cron, expected) in [
(4, 99, None, None),
(4, 99, Some("*/5 * * * *"), None),
(
5,
0,
None,
Some("Maximum 5 active schedules per session. Cancel an existing schedule first."),
),
(
6,
100,
None,
Some("Maximum 5 active schedules per session. Cancel an existing schedule first."),
),
(
0,
100,
None,
Some(
"Maximum 100 active schedules per org reached. Cancel an existing schedule first.",
),
),
(
0,
101,
None,
Some(
"Maximum 100 active schedules per org reached. Cancel an existing schedule first.",
),
),
(
0,
0,
Some("* * * * *"),
Some(
"Schedule cron must fire no more than once every 300 seconds (≥ 5 min); expression fires every 60 seconds",
),
),
] {
let store = Counts {
session: Ok(per_session),
org: Ok(per_org),
};
match (
validate_schedule_create_limits(&store, session, cron).await,
expected,
) {
(Ok(()), None) => {}
(Err(ScheduleLimitError::Rejected(message)), Some(expected)) => {
assert_eq!(message, expected)
}
_ => panic!("unexpected result for {per_session}/{per_org}/{cron:?}"),
}
}
}
#[tokio::test]
async fn count_failures_remain_store_errors_and_session_rejection_wins() {
let session = SessionId::from_seed(9);
for store in [
Counts {
session: Err("session unavailable"),
org: Ok(0),
},
Counts {
session: Ok(0),
org: Err("org unavailable"),
},
] {
let expected = store.session.err().or(store.org.err()).unwrap();
match validate_schedule_create_limits(&store, session, None).await {
Err(ScheduleLimitError::Store(error)) => {
assert_eq!(
error.to_string(),
format!("Message store error: {expected}")
)
}
_ => panic!("count failure must remain a store error"),
}
}
let store = Counts {
session: Ok(5),
org: Err("must not mask session limit"),
};
assert!(
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.")
);
}
}