1use super::traits::{Tool, ToolResult};
2use crate::config::Config;
3use crate::cron::{self, JobType};
4use crate::security::SecurityPolicy;
5use async_trait::async_trait;
6use chrono::Utc;
7use serde_json::json;
8use std::sync::Arc;
9
10pub struct CronRunTool {
11 config: Arc<Config>,
12 security: Arc<SecurityPolicy>,
13}
14
15impl CronRunTool {
16 pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
17 Self { config, security }
18 }
19}
20
21#[async_trait]
22impl Tool for CronRunTool {
23 fn name(&self) -> &str {
24 "cron_run"
25 }
26
27 fn description(&self) -> &str {
28 "Force-run a cron job immediately and record run history"
29 }
30
31 fn parameters_schema(&self) -> serde_json::Value {
32 json!({
33 "type": "object",
34 "properties": {
35 "job_id": { "type": "string" },
36 "approved": {
37 "type": "boolean",
38 "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
39 "default": false
40 }
41 },
42 "required": ["job_id"]
43 })
44 }
45
46 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
47 if !self.config.cron.enabled {
48 return Ok(ToolResult {
49 success: false,
50 output: String::new(),
51 error: Some("cron is disabled by config (cron.enabled=false)".to_string()),
52 });
53 }
54
55 let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
56 Some(v) if !v.trim().is_empty() => v,
57 _ => {
58 return Ok(ToolResult {
59 success: false,
60 output: String::new(),
61 error: Some("Missing 'job_id' parameter".to_string()),
62 });
63 }
64 };
65 let approved = args
66 .get("approved")
67 .and_then(serde_json::Value::as_bool)
68 .unwrap_or(false);
69
70 if !self.security.can_act() {
71 return Ok(ToolResult {
72 success: false,
73 output: String::new(),
74 error: Some("Security policy: read-only mode, cannot perform 'cron_run'".into()),
75 });
76 }
77
78 if self.security.is_rate_limited() {
79 return Ok(ToolResult {
80 success: false,
81 output: String::new(),
82 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
83 });
84 }
85
86 let job = match cron::get_job(&self.config, job_id) {
87 Ok(job) => job,
88 Err(e) => {
89 return Ok(ToolResult {
90 success: false,
91 output: String::new(),
92 error: Some(e.to_string()),
93 });
94 }
95 };
96
97 if matches!(job.job_type, JobType::Shell) {
98 if let Err(reason) = self
99 .security
100 .validate_command_execution(&job.command, approved)
101 {
102 return Ok(ToolResult {
103 success: false,
104 output: String::new(),
105 error: Some(reason),
106 });
107 }
108 }
109
110 if !self.security.record_action() {
111 return Ok(ToolResult {
112 success: false,
113 output: String::new(),
114 error: Some("Rate limit exceeded: action budget exhausted".into()),
115 });
116 }
117
118 let started_at = Utc::now();
119 let (success, output) =
120 Box::pin(cron::scheduler::execute_job_now(&self.config, &job)).await;
121 let finished_at = Utc::now();
122 let duration_ms = (finished_at - started_at).num_milliseconds();
123 let status = if success { "ok" } else { "error" };
124
125 let _ = cron::record_run(
126 &self.config,
127 &job.id,
128 started_at,
129 finished_at,
130 status,
131 Some(&output),
132 duration_ms,
133 );
134 let _ = cron::record_last_run(&self.config, &job.id, finished_at, success, &output);
135
136 Ok(ToolResult {
137 success,
138 output: serde_json::to_string_pretty(&json!({
139 "job_id": job.id,
140 "status": status,
141 "duration_ms": duration_ms,
142 "output": output
143 }))?,
144 error: if success {
145 None
146 } else {
147 Some("cron job execution failed".to_string())
148 },
149 })
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::config::Config;
157 use crate::security::AutonomyLevel;
158 use tempfile::TempDir;
159
160 async fn test_config(tmp: &TempDir) -> Arc<Config> {
161 let config = Config {
162 workspace_dir: tmp.path().join("workspace"),
163 config_path: tmp.path().join("config.toml"),
164 ..Config::default()
165 };
166 tokio::fs::create_dir_all(&config.workspace_dir)
167 .await
168 .unwrap();
169 Arc::new(config)
170 }
171
172 fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
173 Arc::new(SecurityPolicy::from_config(
174 &cfg.autonomy,
175 &cfg.workspace_dir,
176 ))
177 }
178
179 #[tokio::test]
180 async fn force_runs_job_and_records_history() {
181 let tmp = TempDir::new().unwrap();
182 let cfg = test_config(&tmp).await;
183 let job = cron::add_job(&cfg, "*/5 * * * *", "echo run-now").unwrap();
184 let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
185
186 let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
187 assert!(result.success, "{:?}", result.error);
188
189 let runs = cron::list_runs(&cfg, &job.id, 10).unwrap();
190 assert_eq!(runs.len(), 1);
191 }
192
193 #[tokio::test]
194 async fn errors_for_missing_job() {
195 let tmp = TempDir::new().unwrap();
196 let cfg = test_config(&tmp).await;
197 let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
198
199 let result = tool
200 .execute(json!({ "job_id": "missing-job-id" }))
201 .await
202 .unwrap();
203 assert!(!result.success);
204 assert!(result.error.unwrap_or_default().contains("not found"));
205 }
206
207 #[tokio::test]
208 async fn blocks_run_in_read_only_mode() {
209 let tmp = TempDir::new().unwrap();
210 let mut config = Config {
211 workspace_dir: tmp.path().join("workspace"),
212 config_path: tmp.path().join("config.toml"),
213 ..Config::default()
214 };
215 std::fs::create_dir_all(&config.workspace_dir).unwrap();
216 let job = cron::add_job(&config, "*/5 * * * *", "echo run-now").unwrap();
217 config.autonomy.level = AutonomyLevel::ReadOnly;
218 let cfg = Arc::new(config);
219 let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
220
221 let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
222 assert!(!result.success);
223 assert!(result.error.unwrap_or_default().contains("read-only"));
224 }
225
226 #[tokio::test]
227 async fn shell_run_requires_approval_for_medium_risk() {
228 let tmp = TempDir::new().unwrap();
229 let mut config = Config {
230 workspace_dir: tmp.path().join("workspace"),
231 config_path: tmp.path().join("config.toml"),
232 ..Config::default()
233 };
234 config.autonomy.level = AutonomyLevel::Supervised;
235 config.autonomy.allowed_commands = vec!["touch".into()];
236 std::fs::create_dir_all(&config.workspace_dir).unwrap();
237 let cfg = Arc::new(config);
238 let job = cron::add_shell_job_with_approval(
240 &cfg,
241 None,
242 cron::Schedule::Cron {
243 expr: "*/5 * * * *".into(),
244 tz: None,
245 },
246 "touch cron-run-approval",
247 None,
248 true,
249 )
250 .unwrap();
251 let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
252
253 let denied = tool.execute(json!({ "job_id": job.id })).await.unwrap();
255 assert!(!denied.success);
256 assert!(
257 denied
258 .error
259 .unwrap_or_default()
260 .contains("explicit approval")
261 );
262 }
263
264 #[tokio::test]
265 async fn blocks_run_when_rate_limited() {
266 let tmp = TempDir::new().unwrap();
267 let mut config = Config {
268 workspace_dir: tmp.path().join("workspace"),
269 config_path: tmp.path().join("config.toml"),
270 ..Config::default()
271 };
272 config.autonomy.level = AutonomyLevel::Full;
273 config.autonomy.max_actions_per_hour = 0;
274 std::fs::create_dir_all(&config.workspace_dir).unwrap();
275 let cfg = Arc::new(config);
276 let job = cron::add_job(&cfg, "*/5 * * * *", "echo run-now").unwrap();
277 let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
278
279 let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
280 assert!(!result.success);
281 assert!(
282 result
283 .error
284 .unwrap_or_default()
285 .contains("Rate limit exceeded")
286 );
287 assert!(cron::list_runs(&cfg, &job.id, 10).unwrap().is_empty());
288 }
289}