Skip to main content

cersei_tools/
cron.rs

1//! Cron tools: schedule, list, and delete recurring/one-shot tasks.
2
3use super::*;
4use serde::{Deserialize, Serialize};
5
6static CRON_REGISTRY: once_cell::sync::Lazy<dashmap::DashMap<String, CronEntry>> =
7    once_cell::sync::Lazy::new(dashmap::DashMap::new);
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CronEntry {
11    pub id: String,
12    pub schedule: String,
13    pub prompt: String,
14    pub created_at: String,
15    pub last_run: Option<String>,
16    pub run_count: u32,
17}
18
19pub fn list_crons() -> Vec<CronEntry> {
20    CRON_REGISTRY.iter().map(|e| e.value().clone()).collect()
21}
22
23pub fn clear_crons() {
24    CRON_REGISTRY.clear();
25}
26
27// ─── CronCreate ──────────────────────────────────────────────────────────────
28
29pub struct CronCreateTool;
30
31#[async_trait]
32impl Tool for CronCreateTool {
33    fn name(&self) -> &str {
34        "CronCreate"
35    }
36    fn description(&self) -> &str {
37        "Schedule a recurring or one-shot prompt to run on a cron schedule."
38    }
39    fn permission_level(&self) -> PermissionLevel {
40        PermissionLevel::Execute
41    }
42    fn category(&self) -> ToolCategory {
43        ToolCategory::Shell
44    }
45
46    fn input_schema(&self) -> Value {
47        serde_json::json!({
48            "type": "object",
49            "properties": {
50                "schedule": { "type": "string", "description": "Cron expression (e.g. '*/5 * * * *' or 'once:30s')" },
51                "prompt": { "type": "string", "description": "The prompt to execute on schedule" }
52            },
53            "required": ["schedule", "prompt"]
54        })
55    }
56
57    async fn execute(&self, input: Value, _ctx: &ToolContext) -> ToolResult {
58        #[derive(Deserialize)]
59        struct Input {
60            schedule: String,
61            prompt: String,
62        }
63
64        let input: Input = match serde_json::from_value(input) {
65            Ok(i) => i,
66            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
67        };
68
69        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
70        let entry = CronEntry {
71            id: id.clone(),
72            schedule: input.schedule.clone(),
73            prompt: input.prompt.clone(),
74            created_at: chrono::Utc::now().to_rfc3339(),
75            last_run: None,
76            run_count: 0,
77        };
78        CRON_REGISTRY.insert(id.clone(), entry);
79
80        ToolResult::success(format!(
81            "Cron job '{}' created: {} → {}",
82            id, input.schedule, input.prompt
83        ))
84    }
85}
86
87// ─── CronList ────────────────────────────────────────────────────────────────
88
89pub struct CronListTool;
90
91#[async_trait]
92impl Tool for CronListTool {
93    fn name(&self) -> &str {
94        "CronList"
95    }
96    fn description(&self) -> &str {
97        "List all scheduled cron jobs."
98    }
99    fn permission_level(&self) -> PermissionLevel {
100        PermissionLevel::None
101    }
102    fn category(&self) -> ToolCategory {
103        ToolCategory::Shell
104    }
105
106    fn input_schema(&self) -> Value {
107        serde_json::json!({"type": "object", "properties": {}, "required": []})
108    }
109
110    async fn execute(&self, _input: Value, _ctx: &ToolContext) -> ToolResult {
111        let entries = list_crons();
112        if entries.is_empty() {
113            return ToolResult::success("No cron jobs scheduled.");
114        }
115        let lines: Vec<String> = entries
116            .iter()
117            .map(|e| {
118                format!(
119                    "- [{}] {} → {} (runs: {})",
120                    e.id, e.schedule, e.prompt, e.run_count
121                )
122            })
123            .collect();
124        ToolResult::success(lines.join("\n"))
125    }
126}
127
128// ─── CronDelete ──────────────────────────────────────────────────────────────
129
130pub struct CronDeleteTool;
131
132#[async_trait]
133impl Tool for CronDeleteTool {
134    fn name(&self) -> &str {
135        "CronDelete"
136    }
137    fn description(&self) -> &str {
138        "Delete a scheduled cron job by ID."
139    }
140    fn permission_level(&self) -> PermissionLevel {
141        PermissionLevel::Execute
142    }
143    fn category(&self) -> ToolCategory {
144        ToolCategory::Shell
145    }
146
147    fn input_schema(&self) -> Value {
148        serde_json::json!({
149            "type": "object",
150            "properties": {
151                "id": { "type": "string", "description": "Cron job ID to delete" }
152            },
153            "required": ["id"]
154        })
155    }
156
157    async fn execute(&self, input: Value, _ctx: &ToolContext) -> ToolResult {
158        #[derive(Deserialize)]
159        struct Input {
160            id: String,
161        }
162
163        let input: Input = match serde_json::from_value(input) {
164            Ok(i) => i,
165            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
166        };
167
168        if CRON_REGISTRY.remove(&input.id).is_some() {
169            ToolResult::success(format!("Cron job '{}' deleted.", input.id))
170        } else {
171            ToolResult::error(format!("Cron job '{}' not found.", input.id))
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::permissions::AllowAll;
180
181    fn test_ctx() -> ToolContext {
182        ToolContext {
183            working_dir: std::env::temp_dir(),
184            session_id: "cron-test".into(),
185            permissions: Arc::new(AllowAll),
186            cost_tracker: Arc::new(CostTracker::new()),
187            mcp_manager: None,
188            extensions: Extensions::default(),
189        }
190    }
191
192    #[tokio::test]
193    async fn test_cron_lifecycle() {
194        clear_crons();
195
196        let create = CronCreateTool;
197        let result = create
198            .execute(
199                serde_json::json!({
200                    "schedule": "*/5 * * * *",
201                    "prompt": "Run tests"
202                }),
203                &test_ctx(),
204            )
205            .await;
206        assert!(!result.is_error);
207        assert!(result.content.contains("created"));
208
209        let list = CronListTool;
210        let result = list.execute(serde_json::json!({}), &test_ctx()).await;
211        assert!(result.content.contains("Run tests"));
212
213        let entries = list_crons();
214        assert_eq!(entries.len(), 1);
215        let id = entries[0].id.clone();
216
217        let delete = CronDeleteTool;
218        let result = delete
219            .execute(serde_json::json!({"id": id}), &test_ctx())
220            .await;
221        assert!(!result.is_error);
222
223        assert!(list_crons().is_empty());
224    }
225}