use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
use axon_frontend::cron::{cron_expr, CronSchedule};
use axon_frontend::ir_nodes::{IRDaemon, IRFlowNode, IRListenStep, IRRun, IRWindow};
use crate::window::{decide, WindowAction};
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 bound_window<'a>(
ir: &'a axon_frontend::ir_nodes::IRProgram,
daemon: &IRDaemon,
) -> Option<&'a IRWindow> {
if daemon.window_ref.is_empty() {
return None;
}
ir.windows.iter().find(|w| w.name == daemon.window_ref)
}
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,
budget: Option<std::sync::Arc<std::sync::Mutex<crate::runtime::budget_kernel::BudgetGate>>>,
) -> 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,
None,
None,
budget.clone(),
);
(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,
) {
type SharedBudget = Option<std::sync::Arc<std::sync::Mutex<crate::runtime::budget_kernel::BudgetGate>>>;
let (listeners, window, budget): (
Vec<(CronSchedule, Vec<IRFlowNode>, String)>,
Option<IRWindow>,
SharedBudget,
) = {
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;
};
let window = bound_window(&ir, daemon).cloned();
let budget = daemon.budget.as_ref().map(|b| {
std::sync::Arc::new(std::sync::Mutex::new(
crate::runtime::budget_kernel::BudgetGate::from_ir(b, &daemon_name, clock.now()),
))
});
let listeners = cron_listeners(daemon)
.into_iter()
.map(|l| (l.schedule, l.body.to_vec(), l.channel))
.collect();
(listeners, window, budget)
};
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();
let window = window.clone();
let budget = budget.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) => {
if let Some(w) = &window {
match decide(next, w) {
WindowAction::Fire => {} WindowAction::Skip => {
eprintln!(
"§71.c daemon '{daemon_name}' tick at {next} is OUTSIDE \
window '{}' (on_outside: skip) — dropped",
w.name
);
continue;
}
WindowAction::Warn => {
eprintln!(
"§71.c daemon '{daemon_name}' tick at {next} is OUTSIDE \
window '{}' (on_outside: warn) — firing anyway",
w.name
);
}
WindowAction::Defer { open_at } => {
eprintln!(
"§71.c daemon '{daemon_name}' tick at {next} is OUTSIDE \
window '{}' (on_outside: defer) — OSS degrades defer to \
skip (next opening {open_at:?}); the enterprise \
defer-ledger fires it once when the window opens",
w.name
);
continue;
}
}
}
let results =
execute_listener_body(&ir, &body, &backend, "<daemon>", budget.clone());
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");
}
#[test]
fn bound_window_resolves_the_daemon_guard() {
let ir = ir_with_daemon(
"flow Send() -> Unit { step S { ask: \"x\" output: Unit } }\n\
window BusinessHours {\n\
timezone: \"America/Bogota\"\n\
allow: [ { days: Mon..Fri, hours: 9..18 } ]\n\
on_outside: skip\n\
}\n\
daemon Scheduler {\n\
window: BusinessHours\n\
requires: [flow.execute]\n\
listen \"cron:*/5 * * * *\" as tick { run Send() }\n\
}",
);
let daemon = ir.daemons.iter().find(|d| d.name == "Scheduler").unwrap();
assert_eq!(daemon.window_ref, "BusinessHours");
let w = bound_window(&ir, daemon).expect("window resolves");
assert_eq!(w.name, "BusinessHours");
assert_eq!(w.timezone, "America/Bogota");
assert_eq!(w.on_outside, "skip");
let unguarded = ir_with_daemon(
"flow Send() -> Unit { step S { ask: \"x\" output: Unit } }\n\
daemon Plain {\n\
listen \"cron:*/5 * * * *\" as tick { run Send() }\n\
}",
);
let d2 = unguarded.daemons.iter().find(|d| d.name == "Plain").unwrap();
assert!(bound_window(&unguarded, d2).is_none());
}
#[test]
fn budget_gate_builds_from_a_parsed_daemon_and_enforces() {
let ir = ir_with_daemon(
"tool TelnyxCall { provider: telnyx timeout: 5s }\n\
flow SendBatch() -> Unit { step S { ask: \"x\" output: Unit } }\n\
daemon OutboundScheduler {\n\
requires: [flow.execute]\n\
budget {\n\
max: 1 per hour on Tool(TelnyxCall)\n\
on_exhausted: block\n\
}\n\
listen \"cron:*/5 * * * *\" as t { run SendBatch() }\n\
}",
);
let daemon = ir.daemons.iter().find(|d| d.name == "OutboundScheduler").unwrap();
let budget = daemon.budget.as_ref().expect("budget lowered onto the daemon");
let now: chrono::DateTime<chrono::Utc> = "2026-06-29T00:00:00Z".parse().unwrap();
let mut gate = crate::runtime::budget_kernel::BudgetGate::from_ir(budget, &daemon.name, now);
use crate::runtime::budget_kernel::GateDecision;
assert_eq!(gate.gate("TelnyxCall", now), GateDecision::Allow);
match gate.gate("TelnyxCall", now) {
GateDecision::Deny { on_exhausted, .. } => assert_eq!(on_exhausted, "block"),
other => panic!("expected Deny, got {other:?}"),
}
assert_eq!(gate.gate("SomeOtherTool", now), GateDecision::Allow);
}
}