Skip to main content

clawft_kernel/
cron.rs

1//! Cron scheduling service for WeftOS kernel.
2//!
3//! Provides interval-based job scheduling with per-agent targeting.
4//! Jobs fire on a regular interval and dispatch IPC messages to
5//! target agents via the A2ARouter.
6
7use std::collections::HashMap;
8use std::sync::Mutex;
9use std::sync::atomic::{AtomicBool, Ordering};
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use tracing::debug;
15
16#[cfg(feature = "exochain")]
17use crate::gate::GateBackend;
18use crate::health::HealthStatus;
19use crate::process::Pid;
20use crate::service::{ServiceType, SystemService};
21
22/// A scheduled cron job.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CronJob {
25    /// Unique job identifier.
26    pub id: String,
27    /// Human-readable name.
28    pub name: String,
29    /// Fire every N seconds.
30    pub interval_secs: u64,
31    /// Command payload to send.
32    pub command: String,
33    /// Target agent PID (None = kernel).
34    pub target_pid: Option<Pid>,
35    /// Whether the job is active.
36    pub enabled: bool,
37    /// When the job was created.
38    pub created_at: DateTime<Utc>,
39    /// Last time the job fired.
40    pub last_fired: Option<DateTime<Utc>>,
41    /// Number of times the job has fired.
42    pub fire_count: u64,
43}
44
45/// Result of a tick: which jobs fired.
46#[derive(Debug)]
47pub struct TickResult {
48    /// Job IDs that fired during this tick.
49    pub fired: Vec<String>,
50}
51
52/// Cron-specific errors.
53#[non_exhaustive]
54#[derive(Debug, thiserror::Error)]
55pub enum CronError {
56    /// Governance gate denied the operation.
57    #[error("governance denied cron operation '{action}': {reason}")]
58    GovernanceDenied {
59        /// The action that was denied.
60        action: String,
61        /// Reason for denial.
62        reason: String,
63    },
64}
65
66/// Cron scheduling service.
67///
68/// Maintains a registry of interval-based jobs. The daemon calls
69/// `tick()` periodically (e.g., every second) and the service fires
70/// any overdue jobs by sending messages through the A2ARouter.
71pub struct CronService {
72    started: AtomicBool,
73    jobs: Mutex<HashMap<String, CronJob>>,
74    #[cfg(feature = "exochain")]
75    chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
76    #[cfg(feature = "exochain")]
77    governance_gate: Option<std::sync::Arc<crate::gate::GovernanceGate>>,
78}
79
80impl CronService {
81    pub fn new() -> Self {
82        Self {
83            started: AtomicBool::new(false),
84            jobs: Mutex::new(HashMap::new()),
85            #[cfg(feature = "exochain")]
86            chain_manager: None,
87            #[cfg(feature = "exochain")]
88            governance_gate: None,
89        }
90    }
91
92    /// Attach a chain manager for audit logging.
93    #[cfg(feature = "exochain")]
94    pub fn set_chain_manager(
95        &mut self,
96        chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
97    ) {
98        self.chain_manager = chain_manager;
99    }
100
101    /// Attach a governance gate for policy enforcement.
102    #[cfg(feature = "exochain")]
103    pub fn set_governance_gate(
104        &mut self,
105        gate: Option<std::sync::Arc<crate::gate::GovernanceGate>>,
106    ) {
107        self.governance_gate = gate;
108    }
109
110    /// Add a new cron job. Returns the created job.
111    ///
112    /// # Errors
113    ///
114    /// Returns `CronError::GovernanceDenied` if the governance gate
115    /// rejects the job creation (only when the `exochain` feature is
116    /// enabled and a gate is attached).
117    pub fn add_job(
118        &self,
119        name: String,
120        interval_secs: u64,
121        command: String,
122        target_pid: Option<Pid>,
123    ) -> Result<CronJob, CronError> {
124        // Governance gate — block job creation if policy denies it.
125        #[cfg(feature = "exochain")]
126        if let Some(ref gate) = self.governance_gate {
127            use crate::governance::{GateEffectKind, with_effect_context};
128            let context = with_effect_context(
129                serde_json::json!({
130                    "job_name": &name,
131                    "interval_secs": interval_secs,
132                    "command": &command,
133                }),
134                &GateEffectKind::CronAdd.effect_vector(),
135            );
136            let decision = gate.check("kernel", "cron.add", &context);
137            if decision.is_deny() {
138                return Err(CronError::GovernanceDenied {
139                    action: "cron.add".into(),
140                    reason: format!("governance denied adding cron job '{name}'"),
141                });
142            }
143        }
144
145        let job = CronJob {
146            id: uuid::Uuid::new_v4().to_string(),
147            name,
148            interval_secs,
149            command,
150            target_pid,
151            enabled: true,
152            created_at: Utc::now(),
153            last_fired: None,
154            fire_count: 0,
155        };
156
157        let mut jobs = self.jobs.lock().unwrap();
158        jobs.insert(job.id.clone(), job.clone());
159        debug!(job_id = %job.id, name = %job.name, interval = job.interval_secs, "cron job added");
160
161        // Chain logging — record the job creation event.
162        #[cfg(feature = "exochain")]
163        if let Some(ref cm) = self.chain_manager {
164            cm.append(
165                "cron",
166                crate::chain::EVENT_KIND_CRON_ADD,
167                Some(serde_json::json!({
168                    "job_id": &job.id,
169                    "job_name": &job.name,
170                    "interval_secs": job.interval_secs,
171                    "command": &job.command,
172                })),
173            );
174        }
175
176        Ok(job)
177    }
178
179    /// Remove a job by ID. Returns the removed job if it existed.
180    ///
181    /// # Errors
182    ///
183    /// Returns `CronError::GovernanceDenied` if the governance gate
184    /// rejects the job removal (only when the `exochain` feature is
185    /// enabled and a gate is attached).
186    pub fn remove_job(&self, id: &str) -> Result<Option<CronJob>, CronError> {
187        // Governance gate -- block job removal if policy denies it.
188        #[cfg(feature = "exochain")]
189        if let Some(ref gate) = self.governance_gate {
190            use crate::governance::{GateEffectKind, with_effect_context};
191            let context = with_effect_context(
192                serde_json::json!({
193                    "job_id": id,
194                }),
195                &GateEffectKind::CronRemove.effect_vector(),
196            );
197            let decision = gate.check("kernel", "cron.remove", &context);
198            if decision.is_deny() {
199                return Err(CronError::GovernanceDenied {
200                    action: "cron.remove".into(),
201                    reason: format!("governance denied removing cron job '{id}'"),
202                });
203            }
204        }
205
206        let mut jobs = self.jobs.lock().unwrap();
207        let removed = jobs.remove(id);
208        if let Some(ref j) = removed {
209            debug!(job_id = %j.id, name = %j.name, "cron job removed");
210
211            // Chain logging — record the job removal event.
212            #[cfg(feature = "exochain")]
213            if let Some(ref cm) = self.chain_manager {
214                cm.append(
215                    "cron",
216                    crate::chain::EVENT_KIND_CRON_REMOVE,
217                    Some(serde_json::json!({
218                        "job_id": &j.id,
219                        "job_name": &j.name,
220                    })),
221                );
222            }
223        }
224        Ok(removed)
225    }
226
227    /// Enable or disable a job. Returns the updated job, or `None` when the
228    /// id is unknown. A disabled job is skipped by [`tick`](Self::tick).
229    pub fn set_enabled(&self, id: &str, enabled: bool) -> Option<CronJob> {
230        let mut jobs = self.jobs.lock().unwrap();
231        let job = jobs.get_mut(id)?;
232        job.enabled = enabled;
233        debug!(job_id = %job.id, enabled, "cron job enable state changed");
234        Some(job.clone())
235    }
236
237    /// List all registered jobs.
238    pub fn list_jobs(&self) -> Vec<CronJob> {
239        let jobs = self.jobs.lock().unwrap();
240        let mut list: Vec<CronJob> = jobs.values().cloned().collect();
241        list.sort_by_key(|a| a.created_at);
242        list
243    }
244
245    /// Look up a single job by ID.
246    pub fn get_job(&self, id: &str) -> Option<CronJob> {
247        let jobs = self.jobs.lock().unwrap();
248        jobs.get(id).cloned()
249    }
250
251    /// Tick the scheduler — check all jobs and collect those that are overdue.
252    ///
253    /// Returns the list of job IDs that should fire. The caller is responsible
254    /// for actually dispatching the messages (to keep CronService decoupled
255    /// from async A2ARouter).
256    pub fn tick(&self) -> TickResult {
257        let now = Utc::now();
258        let mut fired = Vec::new();
259        let mut jobs = self.jobs.lock().unwrap();
260
261        for job in jobs.values_mut() {
262            if !job.enabled {
263                continue;
264            }
265
266            let should_fire = match job.last_fired {
267                None => true, // Never fired — fire immediately
268                Some(last) => {
269                    let elapsed = (now - last).num_seconds();
270                    elapsed >= job.interval_secs as i64
271                }
272            };
273
274            if should_fire {
275                job.last_fired = Some(now);
276                job.fire_count += 1;
277                fired.push(job.id.clone());
278                debug!(
279                    job_id = %job.id,
280                    name = %job.name,
281                    fire_count = job.fire_count,
282                    "cron job fired"
283                );
284
285                // Chain logging — record which job fired.
286                #[cfg(feature = "exochain")]
287                if let Some(ref cm) = self.chain_manager {
288                    cm.append(
289                        "cron",
290                        crate::chain::EVENT_KIND_CRON_EXECUTE,
291                        Some(serde_json::json!({
292                            "job_id": &job.id,
293                            "job_name": &job.name,
294                            "fire_count": job.fire_count,
295                            "command": &job.command,
296                        })),
297                    );
298                }
299            }
300        }
301
302        TickResult { fired }
303    }
304
305    /// Get a snapshot of a job's current state (for dispatching after tick).
306    pub fn job_snapshot(&self, id: &str) -> Option<CronJob> {
307        self.get_job(id)
308    }
309
310    /// Number of registered jobs.
311    pub fn job_count(&self) -> usize {
312        self.jobs.lock().unwrap().len()
313    }
314}
315
316impl Default for CronService {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322#[async_trait]
323impl SystemService for CronService {
324    fn name(&self) -> &str {
325        "cron"
326    }
327
328    fn service_type(&self) -> ServiceType {
329        ServiceType::Cron
330    }
331
332    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
333        self.started.store(true, Ordering::Relaxed);
334        tracing::info!("cron service started");
335        Ok(())
336    }
337
338    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
339        self.started.store(false, Ordering::Relaxed);
340        tracing::info!("cron service stopped");
341        Ok(())
342    }
343
344    async fn health_check(&self) -> HealthStatus {
345        if self.started.load(Ordering::Relaxed) {
346            HealthStatus::Healthy
347        } else {
348            HealthStatus::Degraded("not started".into())
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn add_and_list_jobs() {
359        let svc = CronService::new();
360        let j1 = svc
361            .add_job("heartbeat".into(), 10, "ping".into(), Some(1))
362            .unwrap();
363        let j2 = svc
364            .add_job("cleanup".into(), 60, "gc".into(), None)
365            .unwrap();
366
367        let jobs = svc.list_jobs();
368        assert_eq!(jobs.len(), 2);
369        assert_eq!(svc.job_count(), 2);
370
371        let fetched = svc.get_job(&j1.id).unwrap();
372        assert_eq!(fetched.name, "heartbeat");
373        assert_eq!(fetched.interval_secs, 10);
374        assert_eq!(fetched.target_pid, Some(1));
375
376        let fetched2 = svc.get_job(&j2.id).unwrap();
377        assert_eq!(fetched2.name, "cleanup");
378    }
379
380    #[test]
381    fn remove_job() {
382        let svc = CronService::new();
383        let job = svc.add_job("temp".into(), 5, "check".into(), None).unwrap();
384
385        let removed = svc.remove_job(&job.id).unwrap();
386        assert!(removed.is_some());
387        assert_eq!(removed.unwrap().name, "temp");
388        assert_eq!(svc.job_count(), 0);
389
390        // Remove again returns None
391        assert!(svc.remove_job(&job.id).unwrap().is_none());
392    }
393
394    #[test]
395    fn tick_fires_new_jobs_immediately() {
396        let svc = CronService::new();
397        svc.add_job("fast".into(), 1, "ping".into(), None).unwrap();
398
399        let result = svc.tick();
400        assert_eq!(result.fired.len(), 1);
401
402        // Verify fire_count incremented
403        let jobs = svc.list_jobs();
404        assert_eq!(jobs[0].fire_count, 1);
405        assert!(jobs[0].last_fired.is_some());
406    }
407
408    #[test]
409    fn tick_respects_interval() {
410        let svc = CronService::new();
411        let _job = svc
412            .add_job("slow".into(), 3600, "check".into(), None)
413            .unwrap();
414
415        // First tick fires (never fired before)
416        let result = svc.tick();
417        assert_eq!(result.fired.len(), 1);
418
419        // Second tick should NOT fire (interval not elapsed)
420        let result2 = svc.tick();
421        assert_eq!(result2.fired.len(), 0);
422    }
423
424    #[test]
425    fn tick_skips_disabled_jobs() {
426        let svc = CronService::new();
427        let job = svc
428            .add_job("disabled".into(), 1, "noop".into(), None)
429            .unwrap();
430
431        // Disable the job
432        {
433            let mut jobs = svc.jobs.lock().unwrap();
434            jobs.get_mut(&job.id).unwrap().enabled = false;
435        }
436
437        let result = svc.tick();
438        assert_eq!(result.fired.len(), 0);
439    }
440
441    #[test]
442    fn empty_tick() {
443        let svc = CronService::new();
444        let result = svc.tick();
445        assert!(result.fired.is_empty());
446    }
447}