1use areev_core::types::{Trigger, TriggerKind};
20
21use crate::error::{Result, TriggerError};
22
23const MIN_HEARTBEAT_SECS: u64 = 60;
26
27pub fn heartbeat_secs(triggers: &[Trigger]) -> u64 {
29 let intervals: Vec<u64> = triggers
30 .iter()
31 .filter(|t| t.enabled)
32 .filter_map(|t| match t.kind {
33 TriggerKind::Interval | TriggerKind::Polling => t.interval_secs.map(u64::from),
34 _ => None,
37 })
38 .filter(|s| *s > 0)
39 .collect();
40
41 let g = intervals.into_iter().reduce(gcd).unwrap_or(MIN_HEARTBEAT_SECS);
42 g.max(MIN_HEARTBEAT_SECS)
43}
44
45fn gcd(a: u64, b: u64) -> u64 {
46 if b == 0 {
47 a
48 } else {
49 gcd(b, a % b)
50 }
51}
52
53pub struct RenderContext<'a> {
55 pub exe: &'a str,
56 pub db: &'a str,
57 pub ns: &'a str,
58 pub heartbeat_secs: u64,
59 pub extra_args: &'a str,
61}
62
63impl RenderContext<'_> {
64 fn command(&self) -> String {
65 let mut c = format!("{} trigger run --db {} --ns {}", self.exe, self.db, self.ns);
66 if !self.extra_args.trim().is_empty() {
67 c.push(' ');
68 c.push_str(self.extra_args.trim());
69 }
70 c
71 }
72}
73
74pub fn render(target: &str, ctx: &RenderContext<'_>) -> Result<String> {
76 match target {
77 "cron" => Ok(cron(ctx)),
78 "launchd" => Ok(launchd(ctx)),
79 "systemd" => Ok(systemd(ctx)),
80 "k8s-cronjob" => Ok(k8s(ctx)),
81 other => Err(TriggerError::UnknownTarget { target: other.to_string() }),
82 }
83}
84
85pub const TARGETS: &[&str] = &["cron", "launchd", "systemd", "k8s-cronjob"];
87
88fn cron_expression(secs: u64) -> String {
89 let mins = (secs / 60).max(1);
91 if mins == 1 {
92 "* * * * *".to_string()
93 } else if mins < 60 && 60 % mins == 0 {
94 format!("*/{mins} * * * *")
95 } else if mins.is_multiple_of(60) && (mins / 60) < 24 {
96 format!("0 */{} * * *", mins / 60)
97 } else {
98 let divisor = [30u64, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1]
103 .into_iter()
104 .find(|d| *d <= mins)
105 .unwrap_or(1);
106 format!("*/{divisor} * * * *")
107 }
108}
109
110fn cron(ctx: &RenderContext<'_>) -> String {
111 format!(
112 "# areev triggers — the memory owns the real cadence; this is only a heartbeat.\n\
113 # Generated by `areev trigger render --target cron`. Apply with `crontab -e`.\n\
114 {} {}\n",
115 cron_expression(ctx.heartbeat_secs),
116 ctx.command()
117 )
118}
119
120fn launchd(ctx: &RenderContext<'_>) -> String {
121 let args: Vec<String> = ctx.command().split_whitespace().map(String::from).collect();
122 let arg_xml: String = args
123 .iter()
124 .map(|a| format!(" <string>{}</string>\n", xml_escape(a)))
125 .collect();
126 format!(
127 r#"<?xml version="1.0" encoding="UTF-8"?>
128<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
129<!-- areev triggers heartbeat. The memory owns the real cadence. -->
130<plist version="1.0">
131<dict>
132 <key>Label</key>
133 <string>ai.areev.triggers</string>
134 <key>ProgramArguments</key>
135 <array>
136{arg_xml} </array>
137 <key>StartInterval</key>
138 <integer>{secs}</integer>
139 <key>RunAtLoad</key>
140 <true/>
141</dict>
142</plist>
143"#,
144 secs = ctx.heartbeat_secs
145 )
146}
147
148fn systemd(ctx: &RenderContext<'_>) -> String {
149 format!(
150 r#"# areev triggers heartbeat. The memory owns the real cadence.
151# Save as areev-triggers.service and areev-triggers.timer, then:
152# systemctl --user enable --now areev-triggers.timer
153
154# ---- areev-triggers.service ----
155[Unit]
156Description=Areev trigger evaluation
157
158[Service]
159Type=oneshot
160ExecStart={cmd}
161
162# ---- areev-triggers.timer ----
163[Unit]
164Description=Areev trigger heartbeat
165
166[Timer]
167OnBootSec={secs}s
168OnUnitActiveSec={secs}s
169# Fire on resume if the machine was asleep through a window — the same
170# catch-up posture the evaluator itself takes.
171Persistent=true
172# Spread wakeups deterministically rather than randomly, so behaviour stays
173# reproducible.
174FixedRandomDelay=true
175RandomizedDelaySec={jitter}s
176
177[Install]
178WantedBy=timers.target
179"#,
180 cmd = ctx.command(),
181 secs = ctx.heartbeat_secs,
182 jitter = (ctx.heartbeat_secs / 10).max(1),
183 )
184}
185
186fn k8s(ctx: &RenderContext<'_>) -> String {
187 let args: Vec<String> = ctx.command().split_whitespace().map(String::from).collect();
188 let arg_yaml: String =
189 args.iter().map(|a| format!(" - {}\n", yaml_scalar(a))).collect();
190 format!(
191 r#"# areev triggers heartbeat. The memory owns the real cadence.
192apiVersion: batch/v1
193kind: CronJob
194metadata:
195 name: areev-triggers
196spec:
197 schedule: "{schedule}"
198 # Forbid: a heartbeat that overlaps itself would have several evaluators
199 # racing for the same claims. They would be safe — losers skip — but it is
200 # wasted work, and the evaluator's own concurrency policy is the right place
201 # to express overlap, per trigger.
202 concurrencyPolicy: Forbid
203 # A tick this late is not worth firing: the memory will decide what is due on
204 # the next one anyway.
205 startingDeadlineSeconds: {deadline}
206 successfulJobsHistoryLimit: 1
207 failedJobsHistoryLimit: 3
208 jobTemplate:
209 spec:
210 template:
211 spec:
212 restartPolicy: OnFailure
213 containers:
214 - name: areev
215 image: areev:latest
216 command:
217{arg_yaml}"#,
218 schedule = cron_expression(ctx.heartbeat_secs),
219 deadline = (ctx.heartbeat_secs * 2).max(120),
220 )
221}
222
223fn xml_escape(s: &str) -> String {
224 s.replace('&', "&").replace('<', "<").replace('>', ">")
225}
226
227fn yaml_scalar(s: &str) -> String {
229 let plain = !s.is_empty()
230 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
231 && !s.starts_with('-');
232 if plain {
233 s.to_string()
234 } else {
235 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
244
245 fn ctx(secs: u64) -> RenderContext<'static> {
246 RenderContext {
247 exe: "areev",
248 db: "accounting.db",
249 ns: "accounting",
250 heartbeat_secs: secs,
251 extra_args: "--connector-cmd ./gmail.sh",
252 }
253 }
254
255 fn interval(secs: u32) -> Trigger {
256 Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
257 }
258
259 #[test]
260 fn the_heartbeat_is_the_gcd_not_the_shortest_interval() {
261 let h = heartbeat_secs(&[interval(120), interval(300)]);
264 assert_eq!(h, 60);
265 }
266
267 #[test]
268 fn the_heartbeat_never_goes_below_a_minute() {
269 assert_eq!(heartbeat_secs(&[interval(5), interval(10)]), 60);
272 }
273
274 #[test]
275 fn a_disabled_trigger_does_not_pull_the_heartbeat_down() {
276 let h = heartbeat_secs(&[
277 interval(600),
278 interval(120).enabled(false),
279 ]);
280 assert_eq!(h, 600, "a disabled trigger should not cost wakeups");
281 }
282
283 #[test]
284 fn cron_triggers_do_not_contribute_a_divisor() {
285 let h = heartbeat_secs(&[Trigger::new(TriggerKind::Schedule, WF).cron("0 9 * * *")]);
288 assert_eq!(h, 60);
289 }
290
291 #[test]
292 fn no_triggers_still_renders_a_sane_heartbeat() {
293 assert_eq!(heartbeat_secs(&[]), 60);
294 }
295
296 #[test]
297 fn cron_expressions_stay_expressible() {
298 assert_eq!(cron_expression(60), "* * * * *");
299 assert_eq!(cron_expression(300), "*/5 * * * *");
300 assert_eq!(cron_expression(3600), "0 */1 * * *");
301 let seven = cron_expression(420);
305 assert!(seven.starts_with("*/"), "{seven}");
306 let n: u64 = seven.trim_start_matches("*/").split(' ').next().unwrap().parse().unwrap();
307 assert_eq!(60 % n, 0, "the rendered period must divide an hour: {seven}");
308 }
309
310 #[test]
311 fn every_declared_target_renders() {
312 for t in TARGETS {
313 let out = render(t, &ctx(120)).unwrap_or_else(|e| panic!("{t}: {e}"));
314 assert!(!out.trim().is_empty(), "{t} rendered nothing");
315 assert!(out.contains("trigger"), "{t} must invoke the evaluator");
318 assert!(out.contains("run"), "{t} must invoke the evaluator");
319 assert!(out.contains("accounting.db"), "{t} must name the memory");
320 }
321 }
322
323 #[test]
324 fn an_unknown_target_is_refused_with_its_code() {
325 let e = render("eventbridge", &ctx(60)).unwrap_err();
326 assert_eq!(e.code(), "TRG-E007");
327 }
328
329 #[test]
330 fn launchd_output_is_well_formed_plist() {
331 let out = render("launchd", &ctx(120)).unwrap();
332 assert!(out.starts_with("<?xml"));
333 assert!(out.contains("<key>StartInterval</key>"));
334 assert!(out.contains("<integer>120</integer>"));
335 assert_eq!(out.matches("<array>").count(), out.matches("</array>").count());
336 assert_eq!(out.matches("<dict>").count(), out.matches("</dict>").count());
337 }
338
339 #[test]
340 fn k8s_quotes_arguments_that_yaml_would_misread() {
341 let out = render("k8s-cronjob", &ctx(120)).unwrap();
342 assert!(out.contains("\"--db\""), "flags must be quoted:\n{out}");
345 assert!(out.contains("concurrencyPolicy: Forbid"));
346 }
347
348 #[test]
349 fn systemd_asks_for_catch_up_and_deterministic_jitter() {
350 let out = render("systemd", &ctx(300)).unwrap();
351 assert!(out.contains("Persistent=true"));
354 assert!(out.contains("FixedRandomDelay=true"));
356 assert!(out.contains("OnUnitActiveSec=300s"));
357 }
358
359 #[test]
360 fn extra_args_reach_the_rendered_command() {
361 for t in TARGETS {
362 let out = render(t, &ctx(60)).unwrap();
363 assert!(out.contains("gmail.sh"), "{t} dropped the connector command");
364 }
365 }
366}