1use std::collections::BTreeMap;
24use std::sync::Mutex;
25use std::time::Duration;
26
27use jiff::Timestamp;
28use layover_core::config::Config;
29use layover_core::pipeline::{PipelineName, Schedule};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Skipped {
34 StillWorking {
36 pipeline: PipelineName,
38 },
39}
40
41impl std::fmt::Display for Skipped {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::StillWorking { pipeline } => write!(
45 f,
46 "`{pipeline}` was due but its previous run has not finished; \
47 set `overlap = \"allow\"` if two at once is safe"
48 ),
49 }
50 }
51}
52
53#[derive(Debug, Default, PartialEq, Eq)]
55pub struct Due {
56 pub fire: Vec<PipelineName>,
58 pub skipped: Vec<Skipped>,
60}
61
62#[derive(Debug)]
64pub struct Clock {
65 next: Mutex<BTreeMap<PipelineName, Timestamp>>,
66}
67
68impl Clock {
69 #[must_use]
75 pub fn new(config: &Config, now: Timestamp) -> Self {
76 let mut next = BTreeMap::new();
77
78 for (name, pipeline) in &config.pipelines {
79 if let Some(schedule) = pipeline.trigger.schedule()
80 && let Some(at) = next_after(schedule, now)
81 {
82 next.insert(name.clone(), at);
83 }
84 }
85
86 Self {
87 next: Mutex::new(next),
88 }
89 }
90
91 pub fn tick(
96 &self,
97 config: &Config,
98 now: Timestamp,
99 working: impl Fn(&PipelineName) -> bool,
100 ) -> Due {
101 let Ok(mut next) = self.next.lock() else {
102 return Due::default();
103 };
104
105 let mut due = Due::default();
106
107 for (name, at) in next.iter_mut() {
108 if *at > now {
109 continue;
110 }
111
112 if let Some(schedule) = config
116 .pipelines
117 .get(name)
118 .and_then(|p| p.trigger.schedule())
119 && let Some(following) = next_after(schedule, now)
120 {
121 *at = following;
122 }
123
124 let overlaps = config
125 .pipelines
126 .get(name)
127 .is_some_and(layover_core::pipeline::Pipeline::allows_overlap);
128
129 if overlaps || !working(name) {
130 due.fire.push(name.clone());
131 } else {
132 due.skipped.push(Skipped::StillWorking {
133 pipeline: name.clone(),
134 });
135 }
136 }
137
138 due
139 }
140
141 #[must_use]
143 pub fn next_due(&self, pipeline: &PipelineName) -> Option<Timestamp> {
144 self.next.lock().ok()?.get(pipeline).copied()
145 }
146
147 #[must_use]
152 pub fn until_next(&self, now: Timestamp) -> Option<Duration> {
153 let next = self.next.lock().ok()?;
154 let soonest = next.values().min()?;
155
156 Some(
157 soonest
158 .duration_since(now)
159 .try_into()
160 .unwrap_or(Duration::ZERO),
161 )
162 }
163}
164
165fn next_after(schedule: &Schedule, now: Timestamp) -> Option<Timestamp> {
167 schedule.next_after(now)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use jiff::ToSpan as _;
174
175 fn factory(pipelines: &str) -> Config {
176 let text = format!(
177 r#"
178[layover]
179work_dir = "work"
180
181[defaults]
182runner = "shell"
183
184[runners.shell]
185command = ["echo"]
186
187[agents.worker]
188prompt = "work"
189entry = true
190
191{pipelines}
192"#
193 );
194
195 toml::from_str(&text).expect("the fixture factory parses")
196 }
197
198 fn name(text: &str) -> PipelineName {
199 PipelineName::new(text)
200 }
201
202 fn never_working(_: &PipelineName) -> bool {
203 false
204 }
205
206 #[test]
207 fn nothing_fires_at_startup() {
208 let config = factory(
211 r#"
212[pipelines.sweep]
213entry = "worker"
214trigger = { every = "1h" }
215"#,
216 );
217
218 let now = Timestamp::now();
219 let clock = Clock::new(&config, now);
220 let due = clock.tick(&config, now, never_working);
221
222 assert!(due.fire.is_empty(), "{:?}", due.fire);
223 assert!(due.skipped.is_empty());
224 }
225
226 #[test]
227 fn an_interval_pipeline_fires_once_its_interval_has_passed() {
228 let config = factory(
229 r#"
230[pipelines.sweep]
231entry = "worker"
232trigger = { every = "1h" }
233"#,
234 );
235
236 let now = Timestamp::now();
237 let clock = Clock::new(&config, now);
238
239 let later = now.checked_add(61_i32.minutes()).expect("in range");
240 let due = clock.tick(&config, later, never_working);
241
242 assert_eq!(due.fire, [name("sweep")]);
243 }
244
245 #[test]
246 fn a_tower_asleep_for_hours_fires_once_on_waking_not_once_per_missed_tick() {
247 let config = factory(
249 r#"
250[pipelines.sweep]
251entry = "worker"
252trigger = { every = "1h" }
253"#,
254 );
255
256 let now = Timestamp::now();
257 let clock = Clock::new(&config, now);
258
259 let much_later = now.checked_add(6_i32.hours()).expect("in range");
260 let due = clock.tick(&config, much_later, never_working);
261
262 assert_eq!(due.fire, [name("sweep")], "one firing, not six");
263 }
264
265 #[test]
266 fn a_pipeline_that_is_still_working_is_skipped_and_says_why() {
267 let config = factory(
269 r#"
270[pipelines.sweep]
271entry = "worker"
272trigger = { every = "1h" }
273"#,
274 );
275
276 let now = Timestamp::now();
277 let clock = Clock::new(&config, now);
278 let later = now.checked_add(61_i32.minutes()).expect("in range");
279
280 let due = clock.tick(&config, later, |_| true);
281
282 assert!(due.fire.is_empty());
283 assert_eq!(
284 due.skipped,
285 [Skipped::StillWorking {
286 pipeline: name("sweep")
287 }]
288 );
289 assert!(
290 due.skipped[0].to_string().contains("overlap"),
291 "a skip should say how to opt out of it: {}",
292 due.skipped[0]
293 );
294 }
295
296 #[test]
297 fn overlap_allow_starts_a_second_copy_deliberately() {
298 let config = factory(
299 r#"
300[pipelines.sweep]
301entry = "worker"
302trigger = { every = "1h" }
303overlap = "allow"
304"#,
305 );
306
307 let now = Timestamp::now();
308 let clock = Clock::new(&config, now);
309 let later = now.checked_add(61_i32.minutes()).expect("in range");
310
311 let due = clock.tick(&config, later, |_| true);
312
313 assert_eq!(due.fire, [name("sweep")]);
314 assert!(due.skipped.is_empty());
315 }
316
317 #[test]
318 fn a_skipped_tick_still_advances_the_clock() {
319 let config = factory(
322 r#"
323[pipelines.sweep]
324entry = "worker"
325trigger = { every = "1h" }
326"#,
327 );
328
329 let now = Timestamp::now();
330 let clock = Clock::new(&config, now);
331 let later = now.checked_add(61_i32.minutes()).expect("in range");
332
333 clock.tick(&config, later, |_| true);
334 let again = clock.tick(&config, later, never_working);
335
336 assert!(
337 again.fire.is_empty(),
338 "the same tick must not fire twice: {:?}",
339 again.fire
340 );
341 }
342
343 #[test]
344 fn a_manual_pipeline_is_never_due() {
345 let config = factory(
346 r#"
347[pipelines.onbehalf]
348entry = "worker"
349"#,
350 );
351
352 let now = Timestamp::now();
353 let clock = Clock::new(&config, now);
354 let far = now.checked_add(30_i32.hours()).expect("in range");
355
356 assert!(clock.tick(&config, far, never_working).fire.is_empty());
357 assert!(clock.until_next(now).is_none(), "nothing to wait for");
358 }
359
360 #[test]
361 fn a_cron_pipeline_is_due_at_its_next_matching_minute() {
362 let config = factory(
363 r#"
364[pipelines.digest]
365entry = "worker"
366trigger = { cron = "* * * * *" }
367"#,
368 );
369
370 let now = Timestamp::now();
371 let clock = Clock::new(&config, now);
372 let next = clock.next_due(&name("digest")).expect("scheduled");
373
374 assert!(next > now, "the next firing is in the future");
375 assert!(
376 next.duration_since(now).as_secs() <= 60,
377 "an every-minute cron should be due within the minute"
378 );
379 }
380
381 #[test]
382 fn the_wait_is_until_the_soonest_pipeline_not_the_first_one_declared() {
383 let config = factory(
384 r#"
385[pipelines.slow]
386entry = "worker"
387trigger = { every = "6h" }
388
389[pipelines.quick]
390entry = "worker"
391trigger = { every = "5m" }
392"#,
393 );
394
395 let now = Timestamp::now();
396 let clock = Clock::new(&config, now);
397
398 let wait = clock.until_next(now).expect("something is scheduled");
399
400 assert!(
401 wait <= Duration::from_mins(5),
402 "should wait for `quick`, not `slow`: {wait:?}"
403 );
404 }
405}