Skip to main content

roder_api/
automations.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5use crate::policy_mode::PolicyMode;
6use crate::tasks::TaskId;
7
8pub type AutomationId = String;
9pub type AutomationRunId = String;
10pub type AutomationOccurrenceKey = String;
11pub type AutomationServerId = String;
12pub type AutomationServerRole = String;
13pub type AutomationClientId = String;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub struct AutomationDefinition {
18    pub id: AutomationId,
19    pub name: String,
20    pub project: AutomationProject,
21    pub schedule: AutomationSchedule,
22    pub prompt: String,
23    pub enabled: bool,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub model_provider: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub model: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub policy_mode: Option<PolicyMode>,
30    pub catch_up: CatchUpPolicy,
31    pub concurrency: AutomationConcurrencyPolicy,
32    pub created_by: AutomationClient,
33    #[serde(with = "time::serde::rfc3339")]
34    pub created_at: OffsetDateTime,
35    #[serde(with = "time::serde::rfc3339")]
36    pub updated_at: OffsetDateTime,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct AutomationProject {
42    pub cwd: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub display_name: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "camelCase", rename_all_fields = "camelCase")]
49pub enum AutomationSchedule {
50    Cron {
51        expression: String,
52        timezone: String,
53    },
54    Interval {
55        seconds: u64,
56    },
57    OneShot {
58        #[serde(with = "time::serde::rfc3339")]
59        run_at: OffsetDateTime,
60    },
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase", rename_all_fields = "camelCase")]
65pub enum CatchUpPolicy {
66    RunAllMissed { max_per_tick: u32 },
67    RunLatestOnly,
68    SkipExpired { grace_seconds: u64 },
69}
70
71#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum AutomationConcurrencyPolicy {
74    Forbid,
75    Allow,
76    ReplaceRunning,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct AutomationClient {
82    pub id: AutomationClientId,
83    pub kind: AutomationClientKind,
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88pub enum AutomationClientKind {
89    AppServer,
90    Desktop,
91    Cli,
92    Tui,
93    System,
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum AutomationRunState {
99    Scheduled,
100    Leased,
101    Queued,
102    Running,
103    Completed,
104    Failed,
105    Skipped,
106    Cancelled,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "camelCase")]
111pub struct AutomationLeaseRecord {
112    pub run_id: AutomationRunId,
113    pub automation_id: AutomationId,
114    pub occurrence_key: AutomationOccurrenceKey,
115    pub server_id: AutomationServerId,
116    pub server_role: AutomationServerRole,
117    #[serde(with = "time::serde::rfc3339")]
118    pub leased_at: OffsetDateTime,
119    #[serde(with = "time::serde::rfc3339")]
120    pub expires_at: OffsetDateTime,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct AutomationRunSummary {
126    pub run_id: AutomationRunId,
127    pub automation_id: AutomationId,
128    pub occurrence_key: AutomationOccurrenceKey,
129    pub state: AutomationRunState,
130    #[serde(with = "time::serde::rfc3339")]
131    pub scheduled_for: OffsetDateTime,
132    #[serde(
133        default,
134        with = "time::serde::rfc3339::option",
135        skip_serializing_if = "Option::is_none"
136    )]
137    pub queued_at: Option<OffsetDateTime>,
138    #[serde(
139        default,
140        with = "time::serde::rfc3339::option",
141        skip_serializing_if = "Option::is_none"
142    )]
143    pub started_at: Option<OffsetDateTime>,
144    #[serde(
145        default,
146        with = "time::serde::rfc3339::option",
147        skip_serializing_if = "Option::is_none"
148    )]
149    pub finished_at: Option<OffsetDateTime>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub thread_id: Option<ThreadId>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub turn_id: Option<TurnId>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub task_id: Option<TaskId>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub server_id: Option<AutomationServerId>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub server_role: Option<AutomationServerRole>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub exit_code: Option<i32>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub error: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub skip_reason: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "camelCase")]
170pub struct AutomationServerDescriptor {
171    pub server_id: AutomationServerId,
172    pub server_role: AutomationServerRole,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "camelCase")]
177pub struct AutomationCreated {
178    pub automation: AutomationDefinition,
179    pub server: AutomationServerDescriptor,
180    #[serde(with = "time::serde::rfc3339")]
181    pub timestamp: OffsetDateTime,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185#[serde(rename_all = "camelCase")]
186pub struct AutomationUpdated {
187    pub automation: AutomationDefinition,
188    pub server: AutomationServerDescriptor,
189    #[serde(with = "time::serde::rfc3339")]
190    pub timestamp: OffsetDateTime,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194#[serde(rename_all = "camelCase")]
195pub struct AutomationDeleted {
196    pub automation_id: AutomationId,
197    pub server: AutomationServerDescriptor,
198    #[serde(with = "time::serde::rfc3339")]
199    pub timestamp: OffsetDateTime,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203#[serde(rename_all = "camelCase")]
204pub struct AutomationDue {
205    pub automation_id: AutomationId,
206    pub occurrence_key: AutomationOccurrenceKey,
207    #[serde(with = "time::serde::rfc3339")]
208    pub scheduled_for: OffsetDateTime,
209    pub server: AutomationServerDescriptor,
210    #[serde(with = "time::serde::rfc3339")]
211    pub timestamp: OffsetDateTime,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "camelCase")]
216pub struct AutomationLeased {
217    pub lease: AutomationLeaseRecord,
218    #[serde(with = "time::serde::rfc3339")]
219    pub timestamp: OffsetDateTime,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[serde(rename_all = "camelCase")]
224pub struct AutomationQueued {
225    pub run: AutomationRunSummary,
226    #[serde(with = "time::serde::rfc3339")]
227    pub timestamp: OffsetDateTime,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(rename_all = "camelCase")]
232pub struct AutomationStarted {
233    pub run: AutomationRunSummary,
234    #[serde(with = "time::serde::rfc3339")]
235    pub timestamp: OffsetDateTime,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[serde(rename_all = "camelCase")]
240pub struct AutomationCompleted {
241    pub run: AutomationRunSummary,
242    #[serde(with = "time::serde::rfc3339")]
243    pub timestamp: OffsetDateTime,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(rename_all = "camelCase")]
248pub struct AutomationFailed {
249    pub run: AutomationRunSummary,
250    pub error: String,
251    #[serde(with = "time::serde::rfc3339")]
252    pub timestamp: OffsetDateTime,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "camelCase")]
257pub struct AutomationSkipped {
258    pub run: AutomationRunSummary,
259    pub reason: String,
260    #[serde(with = "time::serde::rfc3339")]
261    pub timestamp: OffsetDateTime,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct AutomationLeaseExpired {
267    pub lease: AutomationLeaseRecord,
268    #[serde(with = "time::serde::rfc3339")]
269    pub timestamp: OffsetDateTime,
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn timestamp() -> OffsetDateTime {
277        OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()
278    }
279
280    #[test]
281    fn automation_definition_uses_stable_public_shape() {
282        let definition = AutomationDefinition {
283            id: "automation-1".to_string(),
284            name: "Nightly cleanup".to_string(),
285            project: AutomationProject {
286                cwd: "/repo".to_string(),
287                display_name: Some("repo".to_string()),
288            },
289            schedule: AutomationSchedule::Cron {
290                expression: "0 2 * * *".to_string(),
291                timezone: "Europe/London".to_string(),
292            },
293            prompt: "summarize status".to_string(),
294            enabled: true,
295            model_provider: Some("codex".to_string()),
296            model: Some("gpt-5.5".to_string()),
297            policy_mode: Some(PolicyMode::Plan),
298            catch_up: CatchUpPolicy::RunAllMissed { max_per_tick: 3 },
299            concurrency: AutomationConcurrencyPolicy::Forbid,
300            created_by: AutomationClient {
301                id: "desktop-main".to_string(),
302                kind: AutomationClientKind::Desktop,
303            },
304            created_at: timestamp(),
305            updated_at: timestamp(),
306        };
307
308        let value = serde_json::to_value(&definition).unwrap();
309        assert_eq!(value["modelProvider"], "codex");
310        assert_eq!(value["policyMode"], "plan");
311        assert_eq!(value["catchUp"]["runAllMissed"]["maxPerTick"], 3);
312        assert_eq!(value["schedule"]["cron"]["timezone"], "Europe/London");
313        assert!(value.get("model_provider").is_none());
314
315        let round_trip: AutomationDefinition = serde_json::from_value(value).unwrap();
316        assert_eq!(round_trip, definition);
317    }
318
319    #[test]
320    fn run_summary_carries_audit_metadata() {
321        let run = AutomationRunSummary {
322            run_id: "run-1".to_string(),
323            automation_id: "automation-1".to_string(),
324            occurrence_key: "automation-1:2026-05-21T02:00:00Z".to_string(),
325            state: AutomationRunState::Running,
326            scheduled_for: timestamp(),
327            queued_at: Some(timestamp()),
328            started_at: Some(timestamp()),
329            finished_at: None,
330            thread_id: Some("thread-1".to_string()),
331            turn_id: Some("turn-1".to_string()),
332            task_id: Some("task-1".to_string()),
333            server_id: Some("desktop-main".to_string()),
334            server_role: Some("desktop".to_string()),
335            exit_code: None,
336            error: None,
337            skip_reason: None,
338        };
339
340        let value = serde_json::to_value(&run).unwrap();
341        assert_eq!(value["runId"], "run-1");
342        assert_eq!(value["occurrenceKey"], "automation-1:2026-05-21T02:00:00Z");
343        assert_eq!(value["serverId"], "desktop-main");
344        assert!(value.get("finishedAt").is_none());
345    }
346}