oxify_model/
schedule.rs

1//! Workflow scheduling types
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::WorkflowId;
8
9/// Schedule ID type
10pub type ScheduleId = Uuid;
11
12/// Workflow schedule
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct Schedule {
16    /// Unique schedule identifier
17    #[cfg_attr(feature = "openapi", schema(value_type = String))]
18    pub id: ScheduleId,
19
20    /// Workflow to execute
21    #[cfg_attr(feature = "openapi", schema(value_type = String))]
22    pub workflow_id: WorkflowId,
23
24    /// Schedule name
25    pub name: String,
26
27    /// Description
28    pub description: Option<String>,
29
30    /// Cron expression (e.g., "0 0 * * *" for daily at midnight)
31    pub cron: String,
32
33    /// Timezone for schedule (e.g., "UTC", "America/New_York")
34    pub timezone: String,
35
36    /// Whether the schedule is enabled
37    pub enabled: bool,
38
39    /// Input variables for workflow execution
40    pub input_variables: std::collections::HashMap<String, serde_json::Value>,
41
42    /// When the schedule was created
43    pub created_at: DateTime<Utc>,
44
45    /// When the schedule was last modified
46    pub updated_at: DateTime<Utc>,
47
48    /// Last execution time
49    pub last_run: Option<DateTime<Utc>>,
50
51    /// Next scheduled execution time
52    pub next_run: Option<DateTime<Utc>>,
53
54    /// Number of times this schedule has run
55    pub run_count: u64,
56
57    /// Maximum number of times to run (None = infinite)
58    pub max_runs: Option<u64>,
59
60    /// Expiration time (schedule becomes disabled after this)
61    pub expires_at: Option<DateTime<Utc>>,
62}
63
64impl Schedule {
65    /// Create a new schedule
66    pub fn new(workflow_id: WorkflowId, name: String, cron: String) -> Self {
67        let now = Utc::now();
68        Self {
69            id: Uuid::new_v4(),
70            workflow_id,
71            name,
72            description: None,
73            cron,
74            timezone: "UTC".to_string(),
75            enabled: true,
76            input_variables: std::collections::HashMap::new(),
77            created_at: now,
78            updated_at: now,
79            last_run: None,
80            next_run: None,
81            run_count: 0,
82            max_runs: None,
83            expires_at: None,
84        }
85    }
86
87    /// Check if schedule should run
88    pub fn should_run(&self) -> bool {
89        if !self.enabled {
90            return false;
91        }
92
93        // Check if expired
94        if let Some(expires_at) = self.expires_at {
95            if Utc::now() > expires_at {
96                return false;
97            }
98        }
99
100        // Check max runs
101        if let Some(max_runs) = self.max_runs {
102            if self.run_count >= max_runs {
103                return false;
104            }
105        }
106
107        // Check next run time
108        if let Some(next_run) = self.next_run {
109            return Utc::now() >= next_run;
110        }
111
112        false
113    }
114
115    /// Mark schedule as executed
116    pub fn mark_executed(&mut self) {
117        self.last_run = Some(Utc::now());
118        self.run_count += 1;
119    }
120
121    /// Validate cron expression
122    pub fn validate(&self) -> Result<(), String> {
123        // Basic validation - check cron has 5 or 6 parts
124        let parts: Vec<&str> = self.cron.split_whitespace().collect();
125        if parts.len() != 5 && parts.len() != 6 {
126            return Err(format!(
127                "Invalid cron expression '{}': must have 5 or 6 parts",
128                self.cron
129            ));
130        }
131
132        // Validate timezone
133        if self.timezone.is_empty() {
134            return Err("Timezone cannot be empty".to_string());
135        }
136
137        Ok(())
138    }
139}
140
141/// Schedule execution record
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144pub struct ScheduleExecution {
145    /// Unique execution ID
146    #[cfg_attr(feature = "openapi", schema(value_type = String))]
147    pub id: Uuid,
148
149    /// Schedule that triggered this execution
150    #[cfg_attr(feature = "openapi", schema(value_type = String))]
151    pub schedule_id: ScheduleId,
152
153    /// Workflow execution ID
154    #[cfg_attr(feature = "openapi", schema(value_type = String))]
155    pub execution_id: Uuid,
156
157    /// When this execution was triggered
158    pub triggered_at: DateTime<Utc>,
159
160    /// Whether the execution was successful
161    pub success: bool,
162
163    /// Error message if execution failed
164    pub error: Option<String>,
165
166    /// Execution duration in milliseconds
167    pub duration_ms: Option<u64>,
168}
169
170impl ScheduleExecution {
171    /// Create a new schedule execution record
172    pub fn new(schedule_id: ScheduleId, execution_id: Uuid) -> Self {
173        Self {
174            id: Uuid::new_v4(),
175            schedule_id,
176            execution_id,
177            triggered_at: Utc::now(),
178            success: false,
179            error: None,
180            duration_ms: None,
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_schedule_creation() {
191        let workflow_id = Uuid::new_v4();
192        let schedule = Schedule::new(
193            workflow_id,
194            "Daily Report".to_string(),
195            "0 0 * * *".to_string(),
196        );
197
198        assert_eq!(schedule.workflow_id, workflow_id);
199        assert_eq!(schedule.name, "Daily Report");
200        assert_eq!(schedule.cron, "0 0 * * *");
201        assert!(schedule.enabled);
202        assert_eq!(schedule.run_count, 0);
203    }
204
205    #[test]
206    fn test_schedule_validation() {
207        let mut schedule =
208            Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
209
210        assert!(schedule.validate().is_ok());
211
212        // Invalid cron (too few parts)
213        schedule.cron = "0 0 *".to_string();
214        assert!(schedule.validate().is_err());
215
216        // Valid 6-part cron
217        schedule.cron = "0 0 0 * * *".to_string();
218        assert!(schedule.validate().is_ok());
219    }
220
221    #[test]
222    fn test_should_run() {
223        let mut schedule =
224            Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
225
226        // Disabled schedule
227        schedule.enabled = false;
228        assert!(!schedule.should_run());
229
230        // Enabled but no next_run set
231        schedule.enabled = true;
232        assert!(!schedule.should_run());
233
234        // Max runs reached
235        schedule.max_runs = Some(5);
236        schedule.run_count = 5;
237        schedule.next_run = Some(Utc::now());
238        assert!(!schedule.should_run());
239
240        // Expired
241        schedule.max_runs = None;
242        schedule.run_count = 0;
243        schedule.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
244        assert!(!schedule.should_run());
245    }
246
247    #[test]
248    fn test_mark_executed() {
249        let mut schedule =
250            Schedule::new(Uuid::new_v4(), "Test".to_string(), "0 0 * * *".to_string());
251
252        assert_eq!(schedule.run_count, 0);
253        assert!(schedule.last_run.is_none());
254
255        schedule.mark_executed();
256
257        assert_eq!(schedule.run_count, 1);
258        assert!(schedule.last_run.is_some());
259    }
260}