1use 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
27pub 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
87pub 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
128pub 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}