Skip to main content

zeroclaw_runtime/routines/
engine.rs

1//! Routines engine — event-triggered automation with pattern matching and
2//! cooldown enforcement.
3//!
4//! A **routine** is a lightweight automation rule: when an event matches one of
5//! its patterns, the associated action fires (provided cooldown has elapsed).
6//! The engine bridges channel messages, cron ticks, webhooks, and system events
7//! into the existing SOP pipeline.
8
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13
14use super::event_matcher::{EventPattern, RoutineEvent, matches_any};
15
16/// What happens when a routine fires.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum RoutineAction {
20    /// Trigger an SOP by name.
21    Sop { name: String },
22    /// Execute a shell command.
23    Shell { command: String },
24    /// Send a message to a channel.
25    Message { channel: String, text: String },
26    /// Run a cron job by name.
27    CronJob { job_name: String },
28}
29
30/// A single automation routine definition.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Routine {
33    /// Unique name for this routine.
34    pub name: String,
35    /// Human-readable description.
36    #[serde(default)]
37    pub description: String,
38    /// Event patterns that trigger this routine.
39    pub patterns: Vec<EventPattern>,
40    /// Action to execute when triggered.
41    pub action: RoutineAction,
42    /// Minimum seconds between firings (0 = no cooldown).
43    #[serde(default)]
44    pub cooldown_secs: u64,
45    /// Whether this routine is enabled.
46    #[serde(default = "default_enabled")]
47    pub enabled: bool,
48}
49
50fn default_enabled() -> bool {
51    true
52}
53
54/// TOML manifest for a routines file.
55#[derive(Debug, Clone, Deserialize)]
56pub struct RoutinesManifest {
57    #[serde(default)]
58    pub routines: Vec<Routine>,
59}
60
61/// Result of dispatching an event through the routines engine.
62#[derive(Debug, Clone)]
63pub enum RoutineDispatchResult {
64    /// The routine fired successfully.
65    Fired {
66        routine_name: String,
67        action: RoutineAction,
68    },
69    /// The routine matched but is in cooldown.
70    Cooldown {
71        routine_name: String,
72        remaining_secs: u64,
73    },
74    /// The routine matched but is disabled.
75    Disabled { routine_name: String },
76    /// No routine matched the event.
77    NoMatch,
78}
79
80/// The routines engine: holds all loaded routines and tracks cooldowns.
81pub struct RoutinesEngine {
82    routines: Vec<Routine>,
83    /// Last-fired timestamp per routine name.
84    cooldowns: HashMap<String, Instant>,
85}
86
87impl RoutinesEngine {
88    /// Create a new engine with the given routines.
89    pub fn new(routines: Vec<Routine>) -> Self {
90        Self {
91            routines,
92            cooldowns: HashMap::new(),
93        }
94    }
95
96    /// Create an empty engine.
97    pub fn empty() -> Self {
98        Self::new(Vec::new())
99    }
100
101    /// Number of loaded routines.
102    pub fn len(&self) -> usize {
103        self.routines.len()
104    }
105
106    /// Whether the engine has no routines.
107    pub fn is_empty(&self) -> bool {
108        self.routines.is_empty()
109    }
110
111    /// Get all loaded routines.
112    pub fn routines(&self) -> &[Routine] {
113        &self.routines
114    }
115
116    /// Add a routine at runtime.
117    pub fn add_routine(&mut self, routine: Routine) {
118        self.routines.push(routine);
119    }
120
121    /// Remove a routine by name. Returns `true` if removed.
122    pub fn remove_routine(&mut self, name: &str) -> bool {
123        let before = self.routines.len();
124        self.routines.retain(|r| r.name != name);
125        self.cooldowns.remove(name);
126        self.routines.len() < before
127    }
128
129    /// Dispatch an event to all matching routines.
130    ///
131    /// Returns a result for each matching routine (fired, cooldown, or
132    /// disabled).  If no routine matches, returns `[NoMatch]`.
133    pub fn dispatch(&mut self, event: &RoutineEvent) -> Vec<RoutineDispatchResult> {
134        let mut results = Vec::new();
135        let now = Instant::now();
136
137        for routine in &self.routines {
138            if !matches_any(&routine.patterns, event) {
139                continue;
140            }
141
142            if !routine.enabled {
143                ::zeroclaw_log::record!(
144                    DEBUG,
145                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
146                        .with_attrs(::serde_json::json!({"routine": routine.name})),
147                    "routine matched but disabled"
148                );
149                results.push(RoutineDispatchResult::Disabled {
150                    routine_name: routine.name.clone(),
151                });
152                continue;
153            }
154
155            // Check cooldown
156            if routine.cooldown_secs > 0
157                && let Some(last_fired) = self.cooldowns.get(&routine.name)
158            {
159                let elapsed = now.saturating_duration_since(*last_fired);
160                let cooldown = Duration::from_secs(routine.cooldown_secs);
161                if elapsed < cooldown {
162                    let remaining = cooldown.saturating_sub(elapsed).as_secs();
163                    ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"routine": routine.name, "remaining_secs": remaining})), "routine in cooldown");
164                    results.push(RoutineDispatchResult::Cooldown {
165                        routine_name: routine.name.clone(),
166                        remaining_secs: remaining,
167                    });
168                    continue;
169                }
170            }
171
172            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"routine": routine.name, "source": event.source, "topic": event.topic})), "routine fired");
173            self.cooldowns.insert(routine.name.clone(), now);
174            results.push(RoutineDispatchResult::Fired {
175                routine_name: routine.name.clone(),
176                action: routine.action.clone(),
177            });
178        }
179
180        if results.is_empty() {
181            results.push(RoutineDispatchResult::NoMatch);
182        }
183
184        results
185    }
186
187    /// Clear all cooldown state.
188    pub fn reset_cooldowns(&mut self) {
189        self.cooldowns.clear();
190    }
191}
192
193/// Load routines from a TOML file.
194pub fn load_routines_from_file(path: &std::path::Path) -> Vec<Routine> {
195    match std::fs::read_to_string(path) {
196        Ok(content) => match toml::from_str::<RoutinesManifest>(&content) {
197            Ok(manifest) => manifest.routines,
198            Err(e) => {
199                ::zeroclaw_log::record!(
200                    WARN,
201                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
202                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
203                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
204                    &format!(
205                        "Failed to parse routines file {}",
206                        path.display().to_string()
207                    )
208                );
209                Vec::new()
210            }
211        },
212        Err(e) => {
213            ::zeroclaw_log::record!(
214                DEBUG,
215                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
216                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
217                &format!("Routines file not found at {}", path.display().to_string())
218            );
219            Vec::new()
220        }
221    }
222}
223
224/// Load routines from the workspace `routines.toml` file.
225pub fn load_routines(workspace_dir: &std::path::Path) -> Vec<Routine> {
226    let path = workspace_dir.join("routines.toml");
227    load_routines_from_file(&path)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::routines::event_matcher::{EventPattern, MatchStrategy, RoutineEvent};
234
235    fn test_event(source: &str, topic: &str) -> RoutineEvent {
236        RoutineEvent {
237            source: source.into(),
238            topic: topic.into(),
239            payload: None,
240            timestamp: "2026-03-24T00:00:00Z".into(),
241        }
242    }
243
244    fn test_routine(name: &str, source: &str, pattern: &str, strategy: MatchStrategy) -> Routine {
245        Routine {
246            name: name.into(),
247            description: String::new(),
248            patterns: vec![EventPattern {
249                source: source.into(),
250                pattern: pattern.into(),
251                strategy,
252            }],
253            action: RoutineAction::Sop {
254                name: "test-sop".into(),
255            },
256            cooldown_secs: 0,
257            enabled: true,
258        }
259    }
260
261    #[test]
262    fn dispatch_fires_matching_routine() {
263        let mut engine = RoutinesEngine::new(vec![test_routine(
264            "deploy-hook",
265            "webhook",
266            "/deploy",
267            MatchStrategy::Exact,
268        )]);
269
270        let results = engine.dispatch(&test_event("webhook", "/deploy"));
271        assert_eq!(results.len(), 1);
272        assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
273    }
274
275    #[test]
276    fn dispatch_returns_no_match() {
277        let mut engine = RoutinesEngine::new(vec![test_routine(
278            "deploy-hook",
279            "webhook",
280            "/deploy",
281            MatchStrategy::Exact,
282        )]);
283
284        let results = engine.dispatch(&test_event("channel", "slack-main"));
285        assert_eq!(results.len(), 1);
286        assert!(matches!(results[0], RoutineDispatchResult::NoMatch));
287    }
288
289    #[test]
290    fn dispatch_skips_disabled_routine() {
291        let mut routine = test_routine("disabled", "webhook", "/deploy", MatchStrategy::Exact);
292        routine.enabled = false;
293        let mut engine = RoutinesEngine::new(vec![routine]);
294
295        let results = engine.dispatch(&test_event("webhook", "/deploy"));
296        assert_eq!(results.len(), 1);
297        assert!(matches!(results[0], RoutineDispatchResult::Disabled { .. }));
298    }
299
300    #[test]
301    fn dispatch_enforces_cooldown() {
302        let mut routine = test_routine("deploy-hook", "webhook", "/deploy", MatchStrategy::Exact);
303        routine.cooldown_secs = 3600; // 1 hour
304        let mut engine = RoutinesEngine::new(vec![routine]);
305
306        // First dispatch should fire
307        let results = engine.dispatch(&test_event("webhook", "/deploy"));
308        assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
309
310        // Second dispatch should be in cooldown
311        let results = engine.dispatch(&test_event("webhook", "/deploy"));
312        assert!(matches!(results[0], RoutineDispatchResult::Cooldown { .. }));
313    }
314
315    #[test]
316    fn dispatch_multiple_routines_match() {
317        let mut engine = RoutinesEngine::new(vec![
318            test_routine("exact-deploy", "webhook", "/deploy", MatchStrategy::Exact),
319            test_routine("glob-deploy", "webhook", "/deploy*", MatchStrategy::Glob),
320        ]);
321
322        let results = engine.dispatch(&test_event("webhook", "/deploy"));
323        assert_eq!(results.len(), 2);
324        assert!(
325            results
326                .iter()
327                .all(|r| matches!(r, RoutineDispatchResult::Fired { .. }))
328        );
329    }
330
331    #[test]
332    fn reset_cooldowns_clears_state() {
333        let mut routine = test_routine("deploy", "webhook", "/deploy", MatchStrategy::Exact);
334        routine.cooldown_secs = 3600;
335        let mut engine = RoutinesEngine::new(vec![routine]);
336
337        engine.dispatch(&test_event("webhook", "/deploy")); // fires
338        engine.reset_cooldowns();
339        let results = engine.dispatch(&test_event("webhook", "/deploy")); // should fire again
340        assert!(matches!(results[0], RoutineDispatchResult::Fired { .. }));
341    }
342
343    #[test]
344    fn add_and_remove_routine() {
345        let mut engine = RoutinesEngine::empty();
346        assert!(engine.is_empty());
347
348        engine.add_routine(test_routine("r1", "channel", "test", MatchStrategy::Exact));
349        assert_eq!(engine.len(), 1);
350
351        assert!(engine.remove_routine("r1"));
352        assert!(engine.is_empty());
353        assert!(!engine.remove_routine("nonexistent"));
354    }
355
356    #[test]
357    fn load_routines_from_toml_file() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = dir.path().join("routines.toml");
360        std::fs::write(
361            &path,
362            r#"
363[[routines]]
364name = "deploy-notify"
365description = "Notify on deploy"
366cooldown_secs = 60
367
368[[routines.patterns]]
369source = "webhook"
370pattern = "/deploy"
371strategy = "exact"
372
373[routines.action]
374type = "message"
375channel = "slack-general"
376text = "Deploy triggered!"
377
378[[routines]]
379name = "build-monitor"
380description = "Monitor builds"
381
382[[routines.patterns]]
383source = "system"
384pattern = "build.*"
385strategy = "glob"
386
387[routines.action]
388type = "sop"
389name = "check-build"
390"#,
391        )
392        .unwrap();
393
394        let routines = load_routines_from_file(&path);
395        assert_eq!(routines.len(), 2);
396        assert_eq!(routines[0].name, "deploy-notify");
397        assert_eq!(routines[0].cooldown_secs, 60);
398        assert_eq!(routines[1].name, "build-monitor");
399    }
400
401    #[test]
402    fn load_routines_missing_file() {
403        let routines = load_routines_from_file(std::path::Path::new("/nonexistent/routines.toml"));
404        assert!(routines.is_empty());
405    }
406
407    #[test]
408    fn glob_pattern_dispatch() {
409        let mut engine = RoutinesEngine::new(vec![test_routine(
410            "channel-watcher",
411            "channel",
412            "telegram-*",
413            MatchStrategy::Glob,
414        )]);
415
416        assert!(matches!(
417            engine.dispatch(&test_event("channel", "telegram-main"))[0],
418            RoutineDispatchResult::Fired { .. }
419        ));
420        assert!(matches!(
421            engine.dispatch(&test_event("channel", "discord-main"))[0],
422            RoutineDispatchResult::NoMatch
423        ));
424    }
425
426    #[test]
427    fn regex_pattern_dispatch() {
428        let mut engine = RoutinesEngine::new(vec![test_routine(
429            "error-watcher",
430            "system",
431            r"^error\.(critical|fatal)$",
432            MatchStrategy::Regex,
433        )]);
434
435        assert!(matches!(
436            engine.dispatch(&test_event("system", "error.critical"))[0],
437            RoutineDispatchResult::Fired { .. }
438        ));
439        assert!(matches!(
440            engine.dispatch(&test_event("system", "error.warning"))[0],
441            RoutineDispatchResult::NoMatch
442        ));
443    }
444
445    #[test]
446    fn routine_action_serde_roundtrip() {
447        let action = RoutineAction::Sop {
448            name: "test-sop".into(),
449        };
450        let json = serde_json::to_string(&action).unwrap();
451        let parsed: RoutineAction = serde_json::from_str(&json).unwrap();
452        assert!(matches!(parsed, RoutineAction::Sop { name } if name == "test-sop"));
453    }
454}