Skip to main content

bamboo_domain/schedule/
domain.rs

1use crate::reasoning::ReasoningEffort;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5/// Scheduler-owned schedule definition used by the redesigned scheduling domain.
6///
7/// This coexists with the legacy `ScheduleEntry` model during the transition away
8/// from interval-only schedules.
9#[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/// Canonical trigger definition for future schedule implementations.
41///
42/// Kept intentionally independent from any concrete recurrence library so Bamboo
43/// can swap trigger engines without changing the persisted domain model.
44#[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    /// One-shot trigger: fires exactly once at the given absolute UTC instant.
53    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    /// Whether the run has reached a final state (no further transitions). Used
187    /// to decide which run records are safe to prune from history.
188    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/// Runtime configuration for schedule-executed sessions.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
242pub struct ScheduleRunConfig {
243    /// Optional system prompt override for new sessions created by this schedule.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub system_prompt: Option<String>,
246    /// Optional task message to add to the new session.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub task_message: Option<String>,
249    /// Model used when auto-executing.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub model: Option<String>,
252    /// Optional reasoning effort override used when auto-executing.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub reasoning_effort: Option<ReasoningEffort>,
255    /// Optional workspace path context.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub workspace_path: Option<String>,
258    /// Optional enhancement prompt.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub enhance_prompt: Option<String>,
261    /// If true, immediately execute the new session (only meaningful if `task_message` exists).
262    #[serde(default)]
263    pub auto_execute: bool,
264}