Skip to main content

everruns_core/
session_schedule.rs

1// Session schedule domain types
2//
3// Represents scheduled tasks bound to a session.
4// When a schedule fires, a message is injected into the session to trigger a turn.
5
6use 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
16/// Maximum number of active schedules per session.
17pub const MAX_ACTIVE_SCHEDULES_PER_SESSION: u32 = 5;
18
19/// Default minimum seconds between consecutive recurring schedule fires.
20pub const DEFAULT_MIN_INTERVAL_SECONDS: i64 = 300;
21
22/// Default maximum number of active (enabled) schedules per org.
23pub const DEFAULT_MAX_SCHEDULES_PER_ORG: i64 = 100;
24
25/// Returns the minimum interval (seconds) between the next few consecutive
26/// triggers of `cron_expression`, or `None` when the expression cannot be parsed
27/// or fires fewer than twice.
28///
29/// Accepts 5-field (`min hour dom mon dow`) and 6/7-field cron forms; 5-field is
30/// normalized to the seconds-aware form the `cron` crate expects (sec=0, year=*).
31/// This is more permissive than the app schedule channel's
32/// `normalize_cron_expression` (which accepts only 5 or 7 fields): here we also
33/// accept the 6-field seconds form the agent may already pass through to the store.
34pub 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
53/// Validate that a recurring cron does not fire more often than the configured
54/// minimum interval. Returns a user-facing error string when it fires too often.
55///
56/// Unparseable expressions pass here (return `Ok`) and are rejected later at
57/// next-trigger computation, so this gate never false-rejects a valid cron form
58/// it does not recognize.
59pub fn validate_cron_min_interval(cron_expression: &str) -> Result<(), String> {
60    validate_cron_min_interval_with(cron_expression, DEFAULT_MIN_INTERVAL_SECONDS)
61}
62
63/// Validate a cron against a host-selected minimum interval.
64pub 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
79/// Outcome of a failed session-schedule limit check.
80///
81/// Distinguishes a store/count failure (surface as an internal error) from a
82/// limit rejection (surface as a user-facing tool error) so callers preserve the
83/// same behavior they had with the inline checks.
84pub enum ScheduleLimitError {
85    /// Counting active schedules failed.
86    Store(crate::error::AgentLoopError),
87    /// A limit was exceeded; carries the user-facing message.
88    Rejected(String),
89}
90
91/// Enforce the create-time session-schedule limits shared by every agent entry
92/// point (`create_schedule` and `spawn_background` with a `schedule` arg):
93/// per-session cap, per-org cap, and minimum recurring cron interval. Pass the
94/// recurring `cron_expression` (None for one-shot schedules, which skip the
95/// interval gate). Each fire dispatches a real worker turn, so these bound
96/// operator compute on open-signup deployments (see `knowledge/security/threat-model.md`
97/// TM-SCHED-001).
98pub 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/// Type of schedule: one-shot or recurring.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[cfg_attr(feature = "openapi", derive(ToSchema))]
136#[serde(rename_all = "lowercase")]
137pub enum ScheduleType {
138    /// Fires once at `scheduled_at` then auto-disables.
139    OneShot,
140    /// Fires on a cron schedule.
141    Recurring,
142}
143
144/// A session-scoped schedule.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(ToSchema))]
147pub struct SessionSchedule {
148    /// Unique identifier (format: sched_{32-hex}).
149    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "sched_01933b5a00007000800000000000001"))]
150    pub id: ScheduleId,
151    /// Session this schedule belongs to.
152    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
153    pub session_id: SessionId,
154    /// Owning principal for this schedule.
155    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
156    pub owner_principal_id: PrincipalId,
157    /// Denormalized effective human owner of the owning principal lineage.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub resolved_owner_user_id: Option<Uuid>,
160    /// Owning principal summary.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub owner: Option<PrincipalSummary>,
163    /// Effective human owner summary.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub effective_owner: Option<PrincipalSummary>,
166    /// What the agent should do when the schedule fires.
167    pub description: String,
168    /// Cron expression for recurring schedules (None for one-shot).
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub cron_expression: Option<String>,
171    /// One-shot trigger time (None for recurring).
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub scheduled_at: Option<DateTime<Utc>>,
174    /// IANA timezone for cron interpretation.
175    pub timezone: String,
176    /// Whether the schedule is active.
177    pub enabled: bool,
178    /// Computed type based on cron_expression vs scheduled_at.
179    pub schedule_type: ScheduleType,
180    /// Next computed trigger time.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub next_trigger_at: Option<DateTime<Utc>>,
183    /// Last time this schedule fired.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub last_triggered_at: Option<DateTime<Utc>>,
186    /// Total number of times this schedule has fired.
187    pub trigger_count: u32,
188    pub created_at: DateTime<Utc>,
189    pub updated_at: DateTime<Utc>,
190}
191
192impl SessionSchedule {
193    /// Derive schedule type from fields.
194    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}