1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CronJob {
25 pub id: String,
27 pub name: String,
29 pub interval_secs: u64,
31 pub command: String,
33 pub target_pid: Option<Pid>,
35 pub enabled: bool,
37 pub created_at: DateTime<Utc>,
39 pub last_fired: Option<DateTime<Utc>>,
41 pub fire_count: u64,
43}
44
45#[derive(Debug)]
47pub struct TickResult {
48 pub fired: Vec<String>,
50}
51
52#[non_exhaustive]
54#[derive(Debug, thiserror::Error)]
55pub enum CronError {
56 #[error("governance denied cron operation '{action}': {reason}")]
58 GovernanceDenied {
59 action: String,
61 reason: String,
63 },
64}
65
66pub 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 #[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 #[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 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 #[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 #[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 pub fn remove_job(&self, id: &str) -> Result<Option<CronJob>, CronError> {
187 #[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 #[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 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 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 pub fn get_job(&self, id: &str) -> Option<CronJob> {
247 let jobs = self.jobs.lock().unwrap();
248 jobs.get(id).cloned()
249 }
250
251 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, 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 #[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 pub fn job_snapshot(&self, id: &str) -> Option<CronJob> {
307 self.get_job(id)
308 }
309
310 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 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 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 let result = svc.tick();
417 assert_eq!(result.fired.len(), 1);
418
419 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 {
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}