1use std::fmt;
33
34use jiff::{Timestamp, ToSpan};
35use serde::{Deserialize, Serialize};
36use ulid::Ulid;
37
38use crate::agent::AgentName;
39use crate::flight::ItineraryId;
40use crate::handover::Handover;
41
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
44#[serde(transparent)]
45pub struct LayoverId(String);
46
47impl LayoverId {
48 #[must_use]
50 pub fn generate() -> Self {
51 Self(format!("lay_{}", Ulid::new()))
52 }
53
54 #[must_use]
56 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59}
60
61impl fmt::Display for LayoverId {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(&self.0)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Standing {
71 Booked,
73 Resumed,
75 Expired,
77 Cancelled,
79}
80
81impl Standing {
82 #[must_use]
84 pub fn is_pending(self) -> bool {
85 matches!(self, Self::Booked)
86 }
87
88 #[must_use]
90 pub fn slug(self) -> &'static str {
91 match self {
92 Self::Booked => "booked",
93 Self::Resumed => "resumed",
94 Self::Expired => "expired",
95 Self::Cancelled => "cancelled",
96 }
97 }
98
99 #[must_use]
101 pub fn from_slug(slug: &str) -> Option<Self> {
102 [Self::Booked, Self::Resumed, Self::Expired, Self::Cancelled]
103 .into_iter()
104 .find(|standing| standing.slug() == slug)
105 }
106}
107
108impl fmt::Display for Standing {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.write_str(self.slug())
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
116pub struct Layover {
117 pub id: LayoverId,
119 pub agent: AgentName,
121 pub booked_by: ItineraryId,
123 pub waiting_for: String,
125 pub handover: Handover,
127 pub booked_at: Timestamp,
129 pub due_at: Timestamp,
131 pub checks: u32,
137 pub max_checks: u32,
139 pub standing: Standing,
141}
142
143impl Layover {
144 #[must_use]
146 pub fn book(
147 agent: AgentName,
148 booked_by: ItineraryId,
149 waiting_for: impl Into<String>,
150 handover: Handover,
151 booked_at: Timestamp,
152 due_at: Timestamp,
153 max_checks: u32,
154 ) -> Self {
155 Self {
156 id: LayoverId::generate(),
157 agent,
158 booked_by,
159 waiting_for: waiting_for.into(),
160 handover,
161 booked_at,
162 due_at: due_at.max(booked_at),
165 checks: 0,
166 max_checks,
167 standing: Standing::Booked,
168 }
169 }
170
171 #[must_use]
173 pub fn is_due(&self, now: Timestamp) -> bool {
174 self.standing.is_pending() && now >= self.due_at
175 }
176
177 pub fn set_down_again(&mut self, now: Timestamp) {
184 self.checks = self.checks.saturating_add(1);
185
186 if self.checks >= self.max_checks {
187 self.standing = Standing::Expired;
188 return;
189 }
190
191 let minutes = backoff_minutes(self.checks);
192 self.due_at = now.checked_add(minutes.minutes()).unwrap_or(Timestamp::MAX);
193 }
194
195 pub fn resumed(&mut self) {
197 self.standing = Standing::Resumed;
198 }
199
200 pub fn cancel(&mut self) {
202 self.standing = Standing::Cancelled;
203 }
204
205 #[must_use]
207 pub fn minutes_until_due(&self, now: Timestamp) -> Option<i64> {
208 let seconds = self.due_at.as_second() - now.as_second();
209 (seconds > 0).then_some(seconds / 60)
210 }
211}
212
213fn backoff_minutes(checks: u32) -> i64 {
219 const FIRST: i64 = 15;
220 const CAP: i64 = 6 * 60;
221
222 let doublings = checks.saturating_sub(1).min(8);
223 (FIRST << doublings).min(CAP)
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::handover::{Handover, Steer};
230
231 fn at(rfc3339: &str) -> Timestamp {
232 rfc3339.parse().expect("valid timestamp")
233 }
234
235 fn booked(due: &str) -> Layover {
236 Layover::book(
237 "developer".into(),
238 ItineraryId::generate(),
239 "comments on PR 1543477",
240 Handover::steered(
241 Steer {
242 previous: crate::flight::RunId::generate(),
243 note: "address review comments".to_owned(),
244 at: at("2026-09-16T10:00:00Z"),
245 },
246 Vec::new(),
247 ),
248 at("2026-09-16T10:00:00Z"),
249 at(due),
250 8,
251 )
252 }
253
254 #[test]
255 fn a_layover_starts_booked_and_becomes_due_at_its_time() {
256 let layover = booked("2026-09-16T11:00:00Z");
257
258 assert_eq!(layover.standing, Standing::Booked);
259 assert!(!layover.is_due(at("2026-09-16T10:30:00Z")));
260 assert!(layover.is_due(at("2026-09-16T11:00:00Z")));
261 assert_eq!(
262 layover.minutes_until_due(at("2026-09-16T10:30:00Z")),
263 Some(30)
264 );
265 }
266
267 #[test]
268 fn a_layover_due_before_it_was_booked_is_held_to_the_booking_time() {
269 let layover = booked("2026-09-16T09:00:00Z");
271
272 assert_eq!(layover.due_at, at("2026-09-16T10:00:00Z"));
273 }
274
275 #[test]
276 fn checking_and_finding_nothing_pushes_the_next_check_further_out() {
277 let mut layover = booked("2026-09-16T11:00:00Z");
281
282 layover.set_down_again(at("2026-09-16T11:00:00Z"));
283 let first = layover.minutes_until_due(at("2026-09-16T11:00:00Z"));
284
285 layover.set_down_again(at("2026-09-16T11:15:00Z"));
286 let second = layover.minutes_until_due(at("2026-09-16T11:15:00Z"));
287
288 assert_eq!(first, Some(15));
289 assert_eq!(second, Some(30));
290 assert!(second > first);
291 }
292
293 #[test]
294 fn the_backoff_is_capped_so_a_late_comment_is_not_ignored_for_days() {
295 let mut layover = booked("2026-09-16T11:00:00Z");
298 layover.max_checks = 40;
299
300 for _ in 0..20 {
301 layover.set_down_again(at("2026-09-16T11:00:00Z"));
302 }
303
304 assert_eq!(
305 layover.minutes_until_due(at("2026-09-16T11:00:00Z")),
306 Some(6 * 60)
307 );
308 }
309
310 #[test]
311 fn a_layover_that_waits_forever_is_given_up_on() {
312 let mut layover = booked("2026-09-16T11:00:00Z");
315
316 for _ in 0..8 {
317 layover.set_down_again(at("2026-09-16T11:00:00Z"));
318 }
319
320 assert_eq!(layover.standing, Standing::Expired);
321 assert!(!layover.is_due(at("2027-01-01T00:00:00Z")));
322 }
323
324 #[test]
325 fn resuming_and_cancelling_both_take_it_out_of_the_queue() {
326 let mut resumed = booked("2026-09-16T11:00:00Z");
327 resumed.resumed();
328
329 let mut cancelled = booked("2026-09-16T11:00:00Z");
330 cancelled.cancel();
331
332 for layover in [&resumed, &cancelled] {
333 assert!(!layover.standing.is_pending());
334 assert!(!layover.is_due(at("2026-09-16T12:00:00Z")));
335 }
336 }
337
338 #[test]
339 fn the_resumed_run_is_given_what_the_earlier_one_knew() {
340 let layover = booked("2026-09-16T11:00:00Z");
343
344 assert!(layover.handover.brief().contains("address review comments"));
345 }
346
347 #[test]
348 fn standings_round_trip() {
349 for standing in [
350 Standing::Booked,
351 Standing::Resumed,
352 Standing::Expired,
353 Standing::Cancelled,
354 ] {
355 assert_eq!(Standing::from_slug(standing.slug()), Some(standing));
356 }
357 assert_eq!(Standing::from_slug("parked"), None);
358 }
359
360 #[test]
361 fn a_layover_serialises_readably() {
362 let line = serde_json::to_string(&booked("2026-09-16T11:00:00Z")).expect("serialises");
363
364 assert!(line.contains(r#""standing":"booked""#), "{line}");
365 assert!(
366 line.contains(r#""due_at":"2026-09-16T11:00:00Z""#),
367 "{line}"
368 );
369 assert!(line.contains("comments on PR 1543477"), "{line}");
370 }
371}