use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
use axon_frontend::cron::{cron_expr, CronSchedule};
use axon_frontend::ir_nodes::{IRDaemon, IRFlowNode, IRListenStep, IRRun};
pub trait Clock: Send + Sync {
fn now(&self) -> DateTime<Utc>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
pub fn next_fire_after(schedule: &CronSchedule, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
let mut t = (after + Duration::minutes(1))
.with_second(0)
.and_then(|t| t.with_nanosecond(0))?;
let limit = after + Duration::days(366);
while t <= limit {
if matches_minute(schedule, t) {
return Some(t);
}
t += Duration::minutes(1);
}
None
}
pub fn next_fires(schedule: &CronSchedule, from: DateTime<Utc>, n: usize) -> Vec<DateTime<Utc>> {
let mut out = Vec::with_capacity(n);
let mut cursor = from;
for _ in 0..n {
match next_fire_after(schedule, cursor) {
Some(t) => {
out.push(t);
cursor = t;
}
None => break,
}
}
out
}
fn matches_minute(s: &CronSchedule, t: DateTime<Utc>) -> bool {
let dow = t.weekday().num_days_from_sunday(); s.minute.contains(&t.minute())
&& s.hour.contains(&t.hour())
&& s.day_of_month.contains(&t.day())
&& s.month.contains(&t.month())
&& s.day_of_week.contains(&dow)
}
pub struct CronListener<'a> {
pub schedule: CronSchedule,
pub body: &'a [IRFlowNode],
pub channel: String,
}
pub fn cron_listeners(daemon: &IRDaemon) -> Vec<CronListener<'_>> {
daemon
.listeners
.iter()
.filter_map(|l: &IRListenStep| {
let expr = cron_expr(&l.channel)?;
let schedule = CronSchedule::parse(expr).ok()?;
Some(CronListener {
schedule,
body: &l.body,
channel: l.channel.clone(),
})
})
.collect()
}
pub fn run_invocations(body: &[IRFlowNode]) -> Vec<&IRRun> {
body.iter()
.filter_map(|n| match n {
IRFlowNode::Run(r) => Some(r),
_ => None,
})
.collect()
}
pub fn execute_listener_body(
ir: &axon_frontend::ir_nodes::IRProgram,
body: &[IRFlowNode],
backend: &str,
source_file: &str,
) -> Vec<(String, Result<crate::runner::ServerRunnerMetrics, String>)> {
let empty = std::collections::HashMap::new();
run_invocations(body)
.into_iter()
.map(|run| {
let result = crate::runner::execute_server_flow(
ir,
&run.flow_name,
backend,
source_file,
None,
None,
&empty,
&empty,
None,
);
(run.flow_name.clone(), result)
})
.collect()
}
pub async fn run_daemon(
ir: std::sync::Arc<axon_frontend::ir_nodes::IRProgram>,
daemon_name: String,
backend: String,
clock: std::sync::Arc<dyn Clock>,
cancel: crate::cancel_token::CancellationFlag,
) {
let listeners: Vec<(CronSchedule, Vec<IRFlowNode>, String)> = {
let Some(daemon) = ir.daemons.iter().find(|d| d.name == daemon_name) else {
eprintln!("§52.c.2 run_daemon: daemon '{daemon_name}' not in IR — nothing to drive");
return;
};
cron_listeners(daemon)
.into_iter()
.map(|l| (l.schedule, l.body.to_vec(), l.channel))
.collect()
};
let mut tasks = Vec::new();
for (schedule, body, channel) in listeners {
let ir = ir.clone();
let clock = clock.clone();
let cancel = cancel.clone();
let daemon_name = daemon_name.clone();
let backend = backend.clone();
tasks.push(tokio::spawn(async move {
loop {
let now = clock.now();
let Some(next) = next_fire_after(&schedule, now) else {
eprintln!(
"§52.c.2 daemon '{daemon_name}' listener '{channel}': schedule never \
fires within the horizon — stopping this listener"
);
return;
};
let wait = (next - now).to_std().unwrap_or(std::time::Duration::ZERO);
tokio::select! {
_ = cancel.cancelled() => return,
_ = tokio::time::sleep(wait) => {
let results = execute_listener_body(&ir, &body, &backend, "<daemon>");
for (flow, res) in results {
if let Err(e) = res {
eprintln!(
"§52.c.2 daemon '{daemon_name}' tick → flow '{flow}' failed: {e}"
);
}
}
}
}
}
}));
}
for t in tasks {
let _ = t.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()
}
#[test]
fn every_five_minutes_next_fire() {
let s = CronSchedule::parse("*/5 * * * *").unwrap();
assert_eq!(next_fire_after(&s, at(2026, 6, 26, 10, 2)), Some(at(2026, 6, 26, 10, 5)));
assert_eq!(next_fire_after(&s, at(2026, 6, 26, 10, 5)), Some(at(2026, 6, 26, 10, 10)));
assert_eq!(next_fire_after(&s, at(2026, 6, 26, 10, 57)), Some(at(2026, 6, 26, 11, 0)));
}
#[test]
fn daily_at_specific_time_rolls_to_next_day() {
let s = CronSchedule::parse("30 9 * * *").unwrap(); assert_eq!(next_fire_after(&s, at(2026, 6, 26, 9, 0)), Some(at(2026, 6, 26, 9, 30)));
assert_eq!(next_fire_after(&s, at(2026, 6, 26, 9, 30)), Some(at(2026, 6, 27, 9, 30)));
}
#[test]
fn weekday_business_hours_skips_weekend() {
let s = CronSchedule::parse("0 9 * * 1-5").unwrap();
let fired = next_fire_after(&s, at(2026, 6, 26, 10, 0)).unwrap();
assert_eq!(fired, at(2026, 6, 29, 9, 0));
assert_eq!(fired.weekday().num_days_from_sunday(), 1, "Monday");
}
#[test]
fn next_fires_sequence() {
let s = CronSchedule::parse("*/15 * * * *").unwrap();
let fires = next_fires(&s, at(2026, 6, 26, 8, 0), 3);
assert_eq!(
fires,
vec![at(2026, 6, 26, 8, 15), at(2026, 6, 26, 8, 30), at(2026, 6, 26, 8, 45)]
);
}
#[test]
fn impossible_schedule_yields_none() {
let s = CronSchedule::parse("0 0 30 2 *").unwrap();
assert_eq!(next_fire_after(&s, at(2026, 1, 1, 0, 0)), None);
}
fn ir_with_daemon(src: &str) -> axon_frontend::ir_nodes::IRProgram {
let tokens = axon_frontend::lexer::Lexer::new(src, "d.axon").tokenize().unwrap();
let program = axon_frontend::parser::Parser::new(tokens).parse().unwrap();
axon_frontend::ir_generator::IRGenerator::new().generate(&program)
}
#[test]
fn extracts_cron_listeners_and_invocations() {
let ir = ir_with_daemon(
"flow HibernateSession() -> Unit { step S { ask: \"x\" output: Unit } }\n\
daemon Cleaner {\n\
goal: \"clean\"\n\
listen \"cron:*/5 * * * *\" as tick { run HibernateSession() }\n\
listen \"user_events\" as e { run HibernateSession() }\n\
}",
);
let daemon = ir.daemons.iter().find(|d| d.name == "Cleaner").unwrap();
let crons = cron_listeners(daemon);
assert_eq!(crons.len(), 1);
assert_eq!(crons[0].channel, "cron:*/5 * * * *");
let invs = run_invocations(crons[0].body);
assert_eq!(invs.len(), 1);
assert_eq!(invs[0].flow_name, "HibernateSession");
}
}