1use crate::reasoning::ReasoningEffort;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct ScheduleSpec {
11 pub id: String,
12 pub name: String,
13 pub enabled: bool,
14 pub trigger: ScheduleTrigger,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub timezone: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub start_at: Option<DateTime<Utc>>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub end_at: Option<DateTime<Utc>>,
21 #[serde(default)]
22 pub misfire_policy: MisFirePolicy,
23 #[serde(default)]
24 pub overlap_policy: OverlapPolicy,
25 #[serde(default)]
26 pub run_config: ScheduleRunConfig,
27 pub created_at: DateTime<Utc>,
28 pub updated_at: DateTime<Utc>,
29}
30
31impl ScheduleSpec {
32 pub fn window(&self) -> ScheduleWindow {
33 ScheduleWindow {
34 start_at: self.start_at,
35 end_at: self.end_at,
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum ScheduleTrigger {
47 Interval {
48 every_seconds: u64,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 anchor_at: Option<DateTime<Utc>>,
51 },
52 Once {
54 at: DateTime<Utc>,
55 },
56 Daily {
57 hour: u8,
58 minute: u8,
59 #[serde(default)]
60 second: u8,
61 },
62 Weekly {
63 weekdays: Vec<ScheduleWeekday>,
64 hour: u8,
65 minute: u8,
66 #[serde(default)]
67 second: u8,
68 },
69 Monthly {
70 days: Vec<u8>,
71 hour: u8,
72 minute: u8,
73 #[serde(default)]
74 second: u8,
75 },
76 Cron {
77 expr: String,
78 },
79}
80
81impl ScheduleTrigger {
82 pub fn legacy_interval(every_seconds: u64, anchor_at: Option<DateTime<Utc>>) -> Self {
83 Self::Interval {
84 every_seconds,
85 anchor_at,
86 }
87 }
88
89 pub fn kind_name(&self) -> &'static str {
90 match self {
91 Self::Interval { .. } => "interval",
92 Self::Once { .. } => "once",
93 Self::Daily { .. } => "daily",
94 Self::Weekly { .. } => "weekly",
95 Self::Monthly { .. } => "monthly",
96 Self::Cron { .. } => "cron",
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum ScheduleWeekday {
104 Mon,
105 Tue,
106 Wed,
107 Thu,
108 Fri,
109 Sat,
110 Sun,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
114#[serde(tag = "type", rename_all = "snake_case")]
115pub enum MisFirePolicy {
116 #[default]
117 RunOnce,
118 Skip,
119 CatchUpAll,
120 CatchUpWindow {
121 max_catch_up_runs: u32,
122 max_lateness_seconds: u64,
123 },
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum OverlapPolicy {
129 Allow,
130 Skip,
131 #[default]
132 QueueOne,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
136pub struct ScheduleWindow {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub start_at: Option<DateTime<Utc>>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub end_at: Option<DateTime<Utc>>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
144pub struct ScheduleState {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub next_fire_at: Option<DateTime<Utc>>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub last_scheduled_at: Option<DateTime<Utc>>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub last_started_at: Option<DateTime<Utc>>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub last_finished_at: Option<DateTime<Utc>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub last_success_at: Option<DateTime<Utc>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub last_failure_at: Option<DateTime<Utc>>,
157 #[serde(default)]
158 pub queued_run_count: u32,
159 #[serde(default)]
160 pub running_run_count: u32,
161 #[serde(default)]
162 pub consecutive_failures: u32,
163 #[serde(default)]
164 pub total_run_count: u64,
165 #[serde(default)]
166 pub total_success_count: u64,
167 #[serde(default)]
168 pub total_failure_count: u64,
169 #[serde(default)]
170 pub total_missed_count: u64,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum ScheduleRunStatus {
176 Queued,
177 Running,
178 Success,
179 Failed,
180 Skipped,
181 Missed,
182 Cancelled,
183}
184
185impl ScheduleRunStatus {
186 pub fn is_terminal(self) -> bool {
189 matches!(
190 self,
191 Self::Success | Self::Failed | Self::Skipped | Self::Missed | Self::Cancelled
192 )
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ScheduleRunRecord {
198 pub run_id: String,
199 pub schedule_id: String,
200 pub scheduled_for: DateTime<Utc>,
201 pub claimed_at: DateTime<Utc>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub started_at: Option<DateTime<Utc>>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub completed_at: Option<DateTime<Utc>>,
206 pub status: ScheduleRunStatus,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub outcome_reason: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub session_id: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub dispatch_lag_ms: Option<u64>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub execution_duration_ms: Option<u64>,
215 #[serde(default)]
216 pub was_catch_up: bool,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn once_trigger_serializes_with_once_tag_and_round_trips() {
225 let at = DateTime::parse_from_rfc3339("2026-08-01T09:00:00Z")
226 .unwrap()
227 .with_timezone(&Utc);
228 let trigger = ScheduleTrigger::Once { at };
229 assert_eq!(trigger.kind_name(), "once");
230
231 let json = serde_json::to_value(&trigger).unwrap();
232 assert_eq!(json["type"], "once");
233 assert_eq!(json["at"], "2026-08-01T09:00:00Z");
234
235 let parsed: ScheduleTrigger = serde_json::from_value(json).unwrap();
236 assert_eq!(parsed, trigger);
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
242pub struct ScheduleRunConfig {
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub system_prompt: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub task_message: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub model: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub reasoning_effort: Option<ReasoningEffort>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub workspace_path: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub enhance_prompt: Option<String>,
261 #[serde(default)]
263 pub auto_execute: bool,
264}