Skip to main content

a3s_cron/
types.rs

1//! Core types for the cron library
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use uuid::Uuid;
7
8/// Result type alias for cron operations
9pub type Result<T> = std::result::Result<T, CronError>;
10
11/// Trait for executing agent-mode cron jobs.
12///
13/// Implement this trait to provide agent execution capabilities to the cron
14/// scheduler. The server crate implements this using `a3s-code-core::Agent`.
15#[async_trait::async_trait]
16pub trait AgentExecutor: Send + Sync {
17    /// Execute an agent prompt and return the text result.
18    async fn execute(
19        &self,
20        config: &AgentJobConfig,
21        prompt: &str,
22        working_dir: &str,
23    ) -> std::result::Result<String, String>;
24}
25
26/// Cron library errors
27#[derive(Debug, Error)]
28pub enum CronError {
29    /// Invalid cron expression
30    #[error("Invalid cron expression: {0}")]
31    InvalidExpression(String),
32
33    /// Job not found
34    #[error("Job not found: {0}")]
35    JobNotFound(String),
36
37    /// Job already exists
38    #[error("Job already exists: {0}")]
39    JobExists(String),
40
41    /// Storage error
42    #[error("Storage error: {0}")]
43    Storage(String),
44
45    /// Execution error
46    #[error("Execution error: {0}")]
47    Execution(String),
48
49    /// Timeout error
50    #[error("Job execution timed out after {0}ms")]
51    Timeout(u64),
52
53    /// I/O error
54    #[error("I/O error: {0}")]
55    Io(#[from] std::io::Error),
56
57    /// Serialization error
58    #[error("Serialization error: {0}")]
59    Serialization(#[from] serde_json::Error),
60}
61
62/// Job status
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum JobStatus {
66    /// Job is active and will run on schedule
67    Active,
68    /// Job is paused and will not run
69    Paused,
70    /// Job is currently running
71    Running,
72}
73
74/// Job type — determines how the command is executed
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum JobType {
78    /// Execute as a shell command via `sh -c`
79    Shell,
80    /// Execute as an agent prompt via `Agent::send()`
81    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/// Agent configuration for agent-mode cron jobs
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AgentJobConfig {
102    /// LLM model identifier (e.g., "claude-sonnet-4-20250514")
103    pub model: String,
104    /// API key for the LLM provider
105    pub api_key: String,
106    /// Workspace directory (defaults to job working_dir)
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub workspace: Option<String>,
109    /// System prompt override
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub system_prompt: Option<String>,
112    /// Base URL override for the LLM API
113    #[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/// A cron job definition
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CronJob {
130    /// Unique job identifier
131    pub id: String,
132
133    /// Human-readable job name
134    pub name: String,
135
136    /// Cron schedule expression (5 fields: min hour day month weekday)
137    pub schedule: String,
138
139    /// Command to execute (shell command or agent prompt, depending on job_type)
140    pub command: String,
141
142    /// Job type: shell (default) or agent
143    #[serde(default)]
144    pub job_type: JobType,
145
146    /// Agent configuration (required when job_type is Agent)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub agent_config: Option<AgentJobConfig>,
149
150    /// Current job status
151    pub status: JobStatus,
152
153    /// Execution timeout in milliseconds (default: 60000)
154    pub timeout_ms: u64,
155
156    /// Creation timestamp
157    pub created_at: DateTime<Utc>,
158
159    /// Last update timestamp
160    pub updated_at: DateTime<Utc>,
161
162    /// Last execution timestamp
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub last_run: Option<DateTime<Utc>>,
165
166    /// Next scheduled run timestamp
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub next_run: Option<DateTime<Utc>>,
169
170    /// Total successful run count
171    pub run_count: u64,
172
173    /// Total failed run count
174    pub fail_count: u64,
175
176    /// Working directory for command execution
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub working_dir: Option<String>,
179
180    /// Environment variables for command execution
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub env: Vec<(String, String)>,
183}
184
185impl CronJob {
186    /// Create a new cron job
187    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    /// Set the timeout in milliseconds
214    pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
215        self.timeout_ms = timeout_ms;
216        self
217    }
218
219    /// Set the working directory
220    pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
221        self.working_dir = Some(dir.into());
222        self
223    }
224
225    /// Add an environment variable
226    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    /// Check if the job is active
232    pub fn is_active(&self) -> bool {
233        self.status == JobStatus::Active
234    }
235
236    /// Check if the job is paused
237    pub fn is_paused(&self) -> bool {
238        self.status == JobStatus::Paused
239    }
240
241    /// Check if the job is running
242    pub fn is_running(&self) -> bool {
243        self.status == JobStatus::Running
244    }
245}
246
247/// Execution result status
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "lowercase")]
250pub enum ExecutionStatus {
251    /// Execution succeeded
252    Success,
253    /// Execution failed
254    Failed,
255    /// Execution timed out
256    Timeout,
257    /// Execution was cancelled
258    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/// A job execution record
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct JobExecution {
275    /// Execution ID
276    pub id: String,
277
278    /// Job ID
279    pub job_id: String,
280
281    /// Execution status
282    pub status: ExecutionStatus,
283
284    /// Start timestamp
285    pub started_at: DateTime<Utc>,
286
287    /// End timestamp
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub ended_at: Option<DateTime<Utc>>,
290
291    /// Duration in milliseconds
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub duration_ms: Option<u64>,
294
295    /// Exit code (if available)
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub exit_code: Option<i32>,
298
299    /// Standard output (truncated if too long)
300    #[serde(default, skip_serializing_if = "String::is_empty")]
301    pub stdout: String,
302
303    /// Standard error (truncated if too long)
304    #[serde(default, skip_serializing_if = "String::is_empty")]
305    pub stderr: String,
306
307    /// Error message (if failed)
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub error: Option<String>,
310}
311
312impl JobExecution {
313    /// Create a new execution record
314    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    /// Mark execution as completed
330    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    /// Mark execution as failed
346    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    /// Mark execution as timed out
356    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    /// Mark execution as cancelled
366    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
376/// Truncate output to a maximum length
377fn 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}