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}