Skip to main content

construct/tools/
cron_run.rs

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        // Create with explicit approval so the job persists for the run test.
239        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        // Without approval, the tool-level policy check blocks medium-risk commands.
254        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}