1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use uuid::Uuid;
7
8pub type Result<T> = std::result::Result<T, CronError>;
10
11#[async_trait::async_trait]
16pub trait AgentExecutor: Send + Sync {
17 async fn execute(
19 &self,
20 config: &AgentJobConfig,
21 prompt: &str,
22 working_dir: &str,
23 ) -> std::result::Result<String, String>;
24}
25
26#[derive(Debug, Error)]
28pub enum CronError {
29 #[error("Invalid cron expression: {0}")]
31 InvalidExpression(String),
32
33 #[error("Job not found: {0}")]
35 JobNotFound(String),
36
37 #[error("Job already exists: {0}")]
39 JobExists(String),
40
41 #[error("Storage error: {0}")]
43 Storage(String),
44
45 #[error("Execution error: {0}")]
47 Execution(String),
48
49 #[error("Job execution timed out after {0}ms")]
51 Timeout(u64),
52
53 #[error("I/O error: {0}")]
55 Io(#[from] std::io::Error),
56
57 #[error("Serialization error: {0}")]
59 Serialization(#[from] serde_json::Error),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum JobStatus {
66 Active,
68 Paused,
70 Running,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum JobType {
78 Shell,
80 Agent,
82}
83
84impl Default for JobType {
85 fn default() -> Self {
86 Self::Shell
87 }
88}
89
90impl std::fmt::Display for JobType {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 JobType::Shell => write!(f, "shell"),
94 JobType::Agent => write!(f, "agent"),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AgentJobConfig {
102 pub model: String,
104 pub api_key: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub workspace: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub system_prompt: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub base_url: Option<String>,
115}
116
117impl std::fmt::Display for JobStatus {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 JobStatus::Active => write!(f, "active"),
121 JobStatus::Paused => write!(f, "paused"),
122 JobStatus::Running => write!(f, "running"),
123 }
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CronJob {
130 pub id: String,
132
133 pub name: String,
135
136 pub schedule: String,
138
139 pub command: String,
141
142 #[serde(default)]
144 pub job_type: JobType,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub agent_config: Option<AgentJobConfig>,
149
150 pub status: JobStatus,
152
153 pub timeout_ms: u64,
155
156 pub created_at: DateTime<Utc>,
158
159 pub updated_at: DateTime<Utc>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub last_run: Option<DateTime<Utc>>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub next_run: Option<DateTime<Utc>>,
169
170 pub run_count: u64,
172
173 pub fail_count: u64,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub working_dir: Option<String>,
179
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub env: Vec<(String, String)>,
183}
184
185impl CronJob {
186 pub fn new(
188 name: impl Into<String>,
189 schedule: impl Into<String>,
190 command: impl Into<String>,
191 ) -> Self {
192 let now = Utc::now();
193 Self {
194 id: Uuid::new_v4().to_string(),
195 name: name.into(),
196 schedule: schedule.into(),
197 command: command.into(),
198 job_type: JobType::default(),
199 agent_config: None,
200 status: JobStatus::Active,
201 timeout_ms: 60_000,
202 created_at: now,
203 updated_at: now,
204 last_run: None,
205 next_run: None,
206 run_count: 0,
207 fail_count: 0,
208 working_dir: None,
209 env: Vec::new(),
210 }
211 }
212
213 pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
215 self.timeout_ms = timeout_ms;
216 self
217 }
218
219 pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
221 self.working_dir = Some(dir.into());
222 self
223 }
224
225 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
227 self.env.push((key.into(), value.into()));
228 self
229 }
230
231 pub fn is_active(&self) -> bool {
233 self.status == JobStatus::Active
234 }
235
236 pub fn is_paused(&self) -> bool {
238 self.status == JobStatus::Paused
239 }
240
241 pub fn is_running(&self) -> bool {
243 self.status == JobStatus::Running
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "lowercase")]
250pub enum ExecutionStatus {
251 Success,
253 Failed,
255 Timeout,
257 Cancelled,
259}
260
261impl std::fmt::Display for ExecutionStatus {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 match self {
264 ExecutionStatus::Success => write!(f, "success"),
265 ExecutionStatus::Failed => write!(f, "failed"),
266 ExecutionStatus::Timeout => write!(f, "timeout"),
267 ExecutionStatus::Cancelled => write!(f, "cancelled"),
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct JobExecution {
275 pub id: String,
277
278 pub job_id: String,
280
281 pub status: ExecutionStatus,
283
284 pub started_at: DateTime<Utc>,
286
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub ended_at: Option<DateTime<Utc>>,
290
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub duration_ms: Option<u64>,
294
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub exit_code: Option<i32>,
298
299 #[serde(default, skip_serializing_if = "String::is_empty")]
301 pub stdout: String,
302
303 #[serde(default, skip_serializing_if = "String::is_empty")]
305 pub stderr: String,
306
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub error: Option<String>,
310}
311
312impl JobExecution {
313 pub fn new(job_id: impl Into<String>) -> Self {
315 Self {
316 id: Uuid::new_v4().to_string(),
317 job_id: job_id.into(),
318 status: ExecutionStatus::Success,
319 started_at: Utc::now(),
320 ended_at: None,
321 duration_ms: None,
322 exit_code: None,
323 stdout: String::new(),
324 stderr: String::new(),
325 error: None,
326 }
327 }
328
329 pub fn complete(mut self, exit_code: i32, stdout: String, stderr: String) -> Self {
331 let ended_at = Utc::now();
332 self.ended_at = Some(ended_at);
333 self.duration_ms = Some((ended_at - self.started_at).num_milliseconds() as u64);
334 self.exit_code = Some(exit_code);
335 self.stdout = truncate_output(stdout, 10_000);
336 self.stderr = truncate_output(stderr, 10_000);
337 self.status = if exit_code == 0 {
338 ExecutionStatus::Success
339 } else {
340 ExecutionStatus::Failed
341 };
342 self
343 }
344
345 pub fn fail(mut self, error: impl Into<String>) -> Self {
347 let ended_at = Utc::now();
348 self.ended_at = Some(ended_at);
349 self.duration_ms = Some((ended_at - self.started_at).num_milliseconds() as u64);
350 self.status = ExecutionStatus::Failed;
351 self.error = Some(error.into());
352 self
353 }
354
355 pub fn timeout(mut self) -> Self {
357 let ended_at = Utc::now();
358 self.ended_at = Some(ended_at);
359 self.duration_ms = Some((ended_at - self.started_at).num_milliseconds() as u64);
360 self.status = ExecutionStatus::Timeout;
361 self.error = Some("Execution timed out".to_string());
362 self
363 }
364
365 pub fn cancel(mut self) -> Self {
367 let ended_at = Utc::now();
368 self.ended_at = Some(ended_at);
369 self.duration_ms = Some((ended_at - self.started_at).num_milliseconds() as u64);
370 self.status = ExecutionStatus::Cancelled;
371 self.error = Some("Execution cancelled".to_string());
372 self
373 }
374}
375
376fn truncate_output(s: String, max_len: usize) -> String {
378 if s.len() <= max_len {
379 s
380 } else {
381 format!("{}...[truncated]", &s[..max_len])
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_cron_job_new() {
391 let job = CronJob::new("test-job", "*/5 * * * *", "echo hello");
392 assert_eq!(job.name, "test-job");
393 assert_eq!(job.schedule, "*/5 * * * *");
394 assert_eq!(job.command, "echo hello");
395 assert_eq!(job.status, JobStatus::Active);
396 assert_eq!(job.timeout_ms, 60_000);
397 assert!(job.is_active());
398 }
399
400 #[test]
401 fn test_cron_job_builder() {
402 let job = CronJob::new("test", "* * * * *", "cmd")
403 .with_timeout(30_000)
404 .with_working_dir("/tmp")
405 .with_env("KEY", "VALUE");
406
407 assert_eq!(job.timeout_ms, 30_000);
408 assert_eq!(job.working_dir, Some("/tmp".to_string()));
409 assert_eq!(job.env, vec![("KEY".to_string(), "VALUE".to_string())]);
410 }
411
412 #[test]
413 fn test_job_status_display() {
414 assert_eq!(JobStatus::Active.to_string(), "active");
415 assert_eq!(JobStatus::Paused.to_string(), "paused");
416 assert_eq!(JobStatus::Running.to_string(), "running");
417 }
418
419 #[test]
420 fn test_job_execution_complete() {
421 let exec = JobExecution::new("job-1").complete(0, "output".to_string(), "".to_string());
422
423 assert_eq!(exec.status, ExecutionStatus::Success);
424 assert_eq!(exec.exit_code, Some(0));
425 assert!(exec.ended_at.is_some());
426 }
427
428 #[test]
429 fn test_job_execution_fail() {
430 let exec = JobExecution::new("job-1").fail("Something went wrong");
431
432 assert_eq!(exec.status, ExecutionStatus::Failed);
433 assert_eq!(exec.error, Some("Something went wrong".to_string()));
434 }
435
436 #[test]
437 fn test_truncate_output() {
438 let short = "hello".to_string();
439 assert_eq!(truncate_output(short.clone(), 100), short);
440
441 let long = "a".repeat(200);
442 let truncated = truncate_output(long, 100);
443 assert!(truncated.ends_with("...[truncated]"));
444 assert!(truncated.len() < 200);
445 }
446}