Skip to main content

kasl/libs/
pulse.rs

1//! The pulse: the watcher telling kasl-server whether you are working, on a
2//! break, or not in a day - right now, once a minute.
3//!
4//! Everything else kasl sends is about the past: a day goes up when it is
5//! pushed. The pulse is the one thing about the present, and the only side
6//! that knows it is this one - the watcher knows whether it is inside a pause
7//! and whether today's workday is open (ADR 0014 in kasl-server).
8//!
9//! Three rules shape it:
10//!
11//! * **Opt-in, per connection.** Connecting agrees to send finished days. A
12//!   live signal of whether someone is at their keyboard is a different thing
13//!   to agree to, so it waits for `kasl server pulse enable`.
14//! * **The state and nothing else.** Not the task, not the reason for a
15//!   break: those belong to the day, under the server's privacy level.
16//! * **The server owns the cadence.** It answers every pulse with the interval
17//!   it wants and the point at which it stops believing one; the two have to
18//!   agree, so kasl takes both rather than configuring its own.
19//!
20//! The watcher is the only sender. `kasl server pulse enable` and `kasl server
21//! status` read what it recorded in [`ServerPulse`] rather than sending a
22//! pulse of their own, so the server never hears two versions of the present.
23//!
24//! ```rust,no_run
25//! # async fn f(monitor: kasl::libs::monitor::Monitor) {
26//! // Beside the monitor, for as long as the watcher runs.
27//! tokio::spawn(kasl::libs::pulse::run(monitor.pause_flag()));
28//! # }
29//! ```
30
31use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, AgentState, KaslServer, Pulse, UploadError};
32use crate::db::server_pulse::{PulseRecord, ServerPulse};
33use crate::db::workdays::{Workday, Workdays};
34use crate::libs::config::{Config, KaslServerConfig};
35use crate::libs::messages::Message;
36use crate::libs::secret::Secret;
37use anyhow::Result;
38use chrono::{DateTime, Local, Utc};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::{Duration, Instant};
42use tracing::{debug, warn};
43
44/// The interval used until the server has said what it wants.
45pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(60);
46
47/// How long to wait after the server refused a pulse.
48///
49/// A refusal - a revoked token, a server older than the route, a clock too
50/// far ahead - does not come good in a minute, and asking every minute would
51/// only fill the server's log. Turning the pulse on again asks at once, so a
52/// person who has just fixed the cause does not wait out the rest.
53pub const REFUSED_RETRY: Duration = Duration::from_secs(5 * 60);
54
55/// The narrowest and widest interval taken from a server.
56///
57/// A server is trusted with the cadence, not with a busy loop: zero, or an
58/// hour, is a server answering wrongly rather than one with an opinion.
59const INTERVAL_BOUNDS: (u64, u64) = (10, 15 * 60);
60
61/// How often the watcher looks at what it would claim.
62///
63/// The pulse goes once an interval, but a change - into a pause, out of one -
64/// goes as soon as it is seen. Looking costs a config read and one row from
65/// the local database.
66const TICK: Duration = Duration::from_secs(5);
67
68/// How soon a re-enabled pulse may follow the last attempt.
69///
70/// Turning the pulse on clears its record, and a cleared record is the
71/// watcher's cue to send at once - but never faster than this, so a record
72/// that will not stay written cannot become a request every tick.
73const RECORD_RETRY_GAP: Duration = Duration::from_secs(10);
74
75/// How far past its next attempt the watcher may be before `status` calls it
76/// silent. A tick and a slow answer, with room to spare.
77const SILENCE_GRACE_SECONDS: i64 = 30;
78
79/// A clock this far from the server's is worth a sentence in `status`.
80///
81/// Below it is network latency and ordinary drift. The server refuses a
82/// pulse a minute ahead; saying so at ten seconds gives a person the chance
83/// to fix the clock before that happens.
84const SKEW_WORTH_MENTIONING_SECONDS: i64 = 10;
85
86/// What the watcher claims, from what it sees.
87///
88/// "In a day" means today's workday is open: a day closed with `kasl end` is
89/// over even if the keyboard is still busy. Inside an open day, a pause is a
90/// break; outside one there is nothing to be on a break from.
91pub fn presence(in_pause: bool, today: Option<&Workday>) -> AgentState {
92    match today {
93        Some(day) if day.end.is_none() => {
94            if in_pause {
95                AgentState::Paused
96            } else {
97                AgentState::Working
98            }
99        }
100        _ => AgentState::Idle,
101    }
102}
103
104/// The interval a server asked for, held inside [`INTERVAL_BOUNDS`].
105pub fn interval_from(seconds: i64) -> Duration {
106    let (low, high) = INTERVAL_BOUNDS;
107    Duration::from_secs((seconds.max(0) as u64).clamp(low, high))
108}
109
110/// When the next pulse is due.
111///
112/// Due once an interval, and early when the claim changes - a dashboard that
113/// learned about a break a minute late would be a minute wrong at exactly the
114/// moments it is looked at. A failed attempt holds that back: until the wait
115/// is over a change waits too, or a laptop on a train would try again at
116/// every pause.
117#[derive(Debug, Default, Clone)]
118pub struct Cadence {
119    next_at: Option<Instant>,
120    attempted_at: Option<Instant>,
121    sent_state: Option<AgentState>,
122    holding: bool,
123}
124
125impl Cadence {
126    /// Whether to send now, claiming `state`.
127    ///
128    /// `record_missing` is the pulse's record found empty: nobody has reported
129    /// since the pulse was turned on, and the person who turned it on is
130    /// waiting to hear how it went.
131    pub fn is_due(&self, now: Instant, state: AgentState, record_missing: bool) -> bool {
132        let Some(next_at) = self.next_at else {
133            return true;
134        };
135        if now >= next_at {
136            return true;
137        }
138        if !self.holding && self.sent_state != Some(state) {
139            return true;
140        }
141        record_missing && self.attempted_at.is_none_or(|at| now.duration_since(at) >= RECORD_RETRY_GAP)
142    }
143
144    /// Notes a pulse the server took.
145    pub fn sent(&mut self, now: Instant, state: AgentState, wait: Duration) {
146        self.attempted_at = Some(now);
147        self.next_at = Some(now + wait);
148        self.sent_state = Some(state);
149        self.holding = false;
150    }
151
152    /// Notes an attempt that did not arrive; nothing goes before `wait` is up.
153    pub fn failed(&mut self, now: Instant, wait: Duration) {
154        self.attempted_at = Some(now);
155        self.next_at = Some(now + wait);
156        self.holding = true;
157    }
158}
159
160/// What came of one pulse.
161#[derive(Debug, Clone, PartialEq)]
162pub enum Beat {
163    /// The server took it, and recorded this state.
164    Accepted(AgentState),
165
166    /// The server could not answer; worth trying at the next interval.
167    Deferred(String),
168
169    /// The server will not take it as sent; worth trying much later.
170    Refused(String),
171}
172
173/// Sends one pulse claiming `state`, records how it went, and says how long
174/// to wait before the next.
175///
176/// `interval` is the cadence in force - the server's last answer - and is
177/// what a pulse that could not be delivered waits.
178pub async fn beat(client: &KaslServer, token: &str, state: AgentState, interval: Duration, store: &mut ServerPulse) -> Result<(Beat, Duration)> {
179    let at = Local::now().fixed_offset();
180    let now = at.with_timezone(&Utc);
181
182    let (beat, wait) = match client.heartbeat(token, &Pulse { state, at }).await {
183        Ok(accepted) => {
184            let wait = interval_from(accepted.interval_seconds);
185            store.record_accepted(now, now + wait, accepted.state, accepted.stale_after_seconds, accepted.clock_skew_seconds)?;
186            (Beat::Accepted(accepted.state), wait)
187        }
188        Err(UploadError::Retryable { message }) => {
189            store.record_failed(now, now + interval, &message)?;
190            (Beat::Deferred(message), interval)
191        }
192        Err(UploadError::Rejected { status, message }) => {
193            let message = format!("the server refused the pulse ({}): {}", status, message);
194            store.record_failed(now, now + REFUSED_RETRY, &message)?;
195            (Beat::Refused(message), REFUSED_RETRY)
196        }
197    };
198
199    Ok((beat, wait))
200}
201
202/// Where the agent token comes from.
203type TokenSource = Box<dyn Fn() -> Option<String> + Send + Sync>;
204
205/// The watcher's side of the pulse, one tick at a time.
206///
207/// Everything is re-read on each tick - the config, today's workday - so
208/// `kasl server pulse enable` and `disable` take effect within a tick, with no
209/// restart and no second channel into the watcher.
210pub struct Pulser {
211    in_pause: Arc<AtomicBool>,
212    token: TokenSource,
213    cadence: Cadence,
214    interval: Duration,
215    client: Option<(KaslServerConfig, KaslServer)>,
216    workdays: Option<Workdays>,
217    store: Option<ServerPulse>,
218    last_error: Option<String>,
219}
220
221impl Pulser {
222    /// A pulser reading the pause from `in_pause` and the token from the OS
223    /// keyring.
224    pub fn new(in_pause: Arc<AtomicBool>) -> Self {
225        Self::with_token(in_pause, Box::new(|| Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached()))
226    }
227
228    /// A pulser taking its token from `token` - the keyring is not reachable
229    /// from every test runner.
230    pub fn with_token(in_pause: Arc<AtomicBool>, token: TokenSource) -> Self {
231        Self {
232            in_pause,
233            token,
234            cadence: Cadence::default(),
235            interval: DEFAULT_INTERVAL,
236            client: None,
237            workdays: None,
238            store: None,
239            last_error: None,
240        }
241    }
242
243    /// Looks once, and sends a pulse if one is due. Returns what came of it,
244    /// or `None` when nothing was sent.
245    pub async fn tick(&mut self) -> Result<Option<Beat>> {
246        let config = Config::read()?;
247        let Some(server) = config.kasl_server.filter(|server| server.pulse) else {
248            // Off, or not connected. The cadence goes with it, so turning the
249            // pulse on sends at once instead of waiting out an old interval.
250            self.cadence = Cadence::default();
251            self.client = None;
252            return Ok(None);
253        };
254
255        let state = self.claim()?;
256        let record_missing = self.store()?.last()?.is_none();
257        let now = Instant::now();
258        if !self.cadence.is_due(now, state, record_missing) {
259            return Ok(None);
260        }
261
262        let client = match self.client_for(&server) {
263            Ok(client) => client,
264            // A configured certificate that cannot be read: no request can be
265            // built, so this is a refusal of ours rather than the server's.
266            Err(error) => return self.give_up(now, error.to_string()).map(Some),
267        };
268
269        let Some(token) = (self.token)() else {
270            return self
271                .give_up(now, "no agent token is stored - run `kasl server connect` again".to_string())
272                .map(Some);
273        };
274
275        let interval = self.interval;
276        let outcome = beat(&client, &token, state, interval, self.store()?).await;
277        let (beat, wait) = match outcome {
278            Ok(result) => result,
279            Err(error) => {
280                // The record could not be written. Hold off all the same, so a
281                // broken database does not turn into a request every tick.
282                self.cadence.failed(now, interval);
283                self.store = None;
284                return Err(error);
285            }
286        };
287
288        match &beat {
289            Beat::Accepted(_) => {
290                self.interval = wait;
291                self.cadence.sent(now, state, wait);
292                if self.last_error.take().is_some() {
293                    debug!("Pulse: arriving again");
294                }
295            }
296            Beat::Deferred(reason) | Beat::Refused(reason) => {
297                self.cadence.failed(now, wait);
298                self.note_failure(reason);
299            }
300        }
301
302        Ok(Some(beat))
303    }
304
305    /// What to claim right now.
306    fn claim(&mut self) -> Result<AgentState> {
307        let today = Local::now().date_naive();
308        let day = match self.workdays()?.fetch(today) {
309            Ok(day) => day,
310            Err(error) => {
311                // A handle that failed once is not trusted again.
312                self.workdays = None;
313                return Err(error);
314            }
315        };
316        Ok(presence(self.in_pause.load(Ordering::Relaxed), day.as_ref()))
317    }
318
319    /// Records a failure of this side's own, before any request.
320    fn give_up(&mut self, now: Instant, reason: String) -> Result<Beat> {
321        self.cadence.failed(now, REFUSED_RETRY);
322        let at = Utc::now();
323        self.store()?.record_failed(at, at + chrono::Duration::from_std(REFUSED_RETRY)?, &reason)?;
324        self.note_failure(&reason);
325        Ok(Beat::Refused(reason))
326    }
327
328    /// Logs a failure once, not once a minute for as long as it lasts.
329    fn note_failure(&mut self, reason: &str) {
330        if self.last_error.as_deref() != Some(reason) {
331            warn!("Pulse did not arrive: {}", reason);
332            self.last_error = Some(reason.to_string());
333        }
334    }
335
336    fn client_for(&mut self, server: &KaslServerConfig) -> Result<KaslServer> {
337        if let Some((built_for, client)) = &self.client
338            && built_for == server
339        {
340            return Ok(client.clone());
341        }
342        let client = KaslServer::new(server)?;
343        self.client = Some((server.clone(), client.clone()));
344        Ok(client)
345    }
346
347    fn workdays(&mut self) -> Result<&mut Workdays> {
348        if self.workdays.is_none() {
349            self.workdays = Some(Workdays::new()?);
350        }
351        Ok(self.workdays.as_mut().expect("opened above"))
352    }
353
354    fn store(&mut self) -> Result<&mut ServerPulse> {
355        if self.store.is_none() {
356            self.store = Some(ServerPulse::new()?);
357        }
358        Ok(self.store.as_mut().expect("opened above"))
359    }
360}
361
362/// Runs the pulse for as long as the watcher does.
363///
364/// Never returns and never fails: a pulse that cannot go is recorded where
365/// `kasl server status` finds it, and the watcher's real work - recording the
366/// day - must not stop because the team server is unreachable.
367pub async fn run(in_pause: Arc<AtomicBool>) {
368    let mut pulser = Pulser::new(in_pause);
369    loop {
370        match pulser.tick().await {
371            Ok(Some(Beat::Accepted(state))) => debug!("Pulse sent: {}", state),
372            Ok(_) => {}
373            Err(error) => warn!("Pulse: {}", error),
374        }
375        tokio::time::sleep(TICK).await;
376    }
377}
378
379/// One line of the pulse's report, and whether it is a warning.
380#[derive(Debug)]
381pub struct StatusLine {
382    pub warning: bool,
383    pub message: Message,
384}
385
386impl StatusLine {
387    fn info(message: Message) -> Self {
388        Self { warning: false, message }
389    }
390
391    fn warning(message: Message) -> Self {
392        Self { warning: true, message }
393    }
394}
395
396/// What `kasl server status` says about the pulse.
397///
398/// Built from the record alone, so it answers offline and needs no running
399/// watcher to ask. Whether a watcher is sending at all is read from the record
400/// too: one that is running moves `next_at` forward, one that is gone leaves
401/// it behind - which holds for a background and a foreground watcher alike.
402pub fn describe(enabled: bool, record: Option<&PulseRecord>, now: DateTime<Utc>) -> Vec<StatusLine> {
403    if !enabled {
404        return vec![StatusLine::info(Message::PulseOff)];
405    }
406    let Some(record) = record else {
407        return vec![StatusLine::info(Message::PulseNothingYet)];
408    };
409
410    let ago = |at: DateTime<Utc>| format_ago((now - at).num_seconds());
411    let mut lines = Vec::new();
412
413    match &record.error {
414        None => lines.push(StatusLine::info(Message::PulseLastSent {
415            ago: ago(record.sent_at.unwrap_or(record.attempted_at)),
416            state: record.state.map(|state| state.to_string()),
417        })),
418        Some(error) => {
419            lines.push(StatusLine::warning(Message::PulseFailing {
420                ago: ago(record.attempted_at),
421                error: error.clone(),
422            }));
423            if let Some(sent_at) = record.sent_at {
424                lines.push(StatusLine::info(Message::PulseLastArrived(ago(sent_at))));
425            }
426        }
427    }
428
429    if (now - record.next_at).num_seconds() > SILENCE_GRACE_SECONDS {
430        lines.push(StatusLine::warning(Message::PulseSilent(ago(record.next_at))));
431    }
432
433    if let (Some(sent_at), Some(stale_after)) = (record.sent_at, record.stale_after_seconds)
434        && (now - sent_at).num_seconds() > stale_after
435    {
436        lines.push(StatusLine::warning(Message::PulseShownOffline(format_ago(stale_after))));
437    }
438
439    if let Some(skew) = record.clock_skew_seconds
440        && skew.abs() >= SKEW_WORTH_MENTIONING_SECONDS
441    {
442        lines.push(StatusLine::warning(Message::PulseClockSkew(skew)));
443    }
444
445    lines
446}
447
448/// A span of seconds the way a person reads it: `42 s`, `6 min`, `2 h 5 min`,
449/// `3 d`.
450///
451/// Coarser as it grows, because nobody acts on the seconds of something three
452/// hours old. A negative span - a clock set back since - reads as none.
453pub fn format_ago(seconds: i64) -> String {
454    let seconds = seconds.max(0);
455    match seconds {
456        0..60 => format!("{} s", seconds),
457        60..3600 => format!("{} min", seconds / 60),
458        3600..86400 => match (seconds % 3600) / 60 {
459            0 => format!("{} h", seconds / 3600),
460            minutes => format!("{} h {} min", seconds / 3600, minutes),
461        },
462        _ => format!("{} d", seconds / 86400),
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use chrono::{NaiveDate, NaiveDateTime};
470
471    fn day(end: Option<NaiveDateTime>) -> Workday {
472        let date = NaiveDate::from_ymd_opt(2026, 10, 2).unwrap();
473        Workday {
474            id: 1,
475            date,
476            start: date.and_hms_opt(9, 0, 0).unwrap(),
477            end,
478        }
479    }
480
481    #[test]
482    fn an_open_day_is_working_or_paused() {
483        let open = day(None);
484        assert_eq!(presence(false, Some(&open)), AgentState::Working);
485        assert_eq!(presence(true, Some(&open)), AgentState::Paused);
486    }
487
488    #[test]
489    fn no_day_or_a_closed_one_is_idle_whatever_the_monitor_says() {
490        // A pause outside a day is not a break from anything, and a day closed
491        // with `kasl end` is over even if the keyboard is still busy.
492        let closed = day(Some(NaiveDate::from_ymd_opt(2026, 10, 2).unwrap().and_hms_opt(18, 0, 0).unwrap()));
493        for in_pause in [false, true] {
494            assert_eq!(presence(in_pause, None), AgentState::Idle);
495            assert_eq!(presence(in_pause, Some(&closed)), AgentState::Idle);
496        }
497    }
498
499    #[test]
500    fn a_server_interval_is_taken_within_bounds() {
501        assert_eq!(interval_from(60), Duration::from_secs(60));
502        // Zero would be a busy loop and an hour a dashboard that is always
503        // stale; both are a server answering wrongly.
504        assert_eq!(interval_from(0), Duration::from_secs(INTERVAL_BOUNDS.0));
505        assert_eq!(interval_from(-5), Duration::from_secs(INTERVAL_BOUNDS.0));
506        assert_eq!(interval_from(3600), Duration::from_secs(INTERVAL_BOUNDS.1));
507    }
508
509    #[test]
510    fn the_first_pulse_goes_at_once() {
511        assert!(Cadence::default().is_due(Instant::now(), AgentState::Working, false));
512    }
513
514    #[test]
515    fn an_unchanged_claim_waits_for_the_interval() {
516        let start = Instant::now();
517        let mut cadence = Cadence::default();
518        cadence.sent(start, AgentState::Working, Duration::from_secs(60));
519
520        assert!(!cadence.is_due(start + Duration::from_secs(59), AgentState::Working, false));
521        assert!(cadence.is_due(start + Duration::from_secs(60), AgentState::Working, false));
522    }
523
524    #[test]
525    fn a_changed_claim_goes_without_waiting() {
526        // The dashboard is looked at when someone wonders where a colleague
527        // went; a break reported a minute late is wrong at exactly that moment.
528        let start = Instant::now();
529        let mut cadence = Cadence::default();
530        cadence.sent(start, AgentState::Working, Duration::from_secs(60));
531
532        assert!(cadence.is_due(start + Duration::from_secs(5), AgentState::Paused, false));
533    }
534
535    #[test]
536    fn after_a_failure_even_a_change_waits() {
537        // Offline on a train, every pause would otherwise be another request
538        // into a network that is not there.
539        let start = Instant::now();
540        let mut cadence = Cadence::default();
541        cadence.sent(start, AgentState::Working, Duration::from_secs(60));
542        cadence.failed(start + Duration::from_secs(60), Duration::from_secs(60));
543
544        assert!(!cadence.is_due(start + Duration::from_secs(70), AgentState::Paused, false));
545        assert!(cadence.is_due(start + Duration::from_secs(120), AgentState::Paused, false));
546    }
547
548    #[test]
549    fn a_cleared_record_asks_again_but_not_every_tick() {
550        // Turning the pulse on again is the person's "try now" - after a
551        // refusal they have just fixed, waiting five minutes would read as
552        // the fix not working.
553        let start = Instant::now();
554        let mut cadence = Cadence::default();
555        cadence.failed(start, REFUSED_RETRY);
556
557        assert!(!cadence.is_due(start + Duration::from_secs(5), AgentState::Working, true));
558        assert!(cadence.is_due(start + RECORD_RETRY_GAP, AgentState::Working, true));
559        assert!(!cadence.is_due(start + RECORD_RETRY_GAP, AgentState::Working, false));
560    }
561
562    #[test]
563    fn spans_read_coarser_as_they_grow() {
564        assert_eq!(format_ago(0), "0 s");
565        assert_eq!(format_ago(42), "42 s");
566        assert_eq!(format_ago(60), "1 min");
567        assert_eq!(format_ago(359), "5 min");
568        assert_eq!(format_ago(3600), "1 h");
569        assert_eq!(format_ago(7500), "2 h 5 min");
570        assert_eq!(format_ago(3 * 86400 + 5), "3 d");
571        assert_eq!(format_ago(-30), "0 s");
572    }
573
574    fn at(seconds_before: i64, now: DateTime<Utc>) -> DateTime<Utc> {
575        now - chrono::Duration::seconds(seconds_before)
576    }
577
578    fn accepted(now: DateTime<Utc>, sent_ago: i64) -> PulseRecord {
579        PulseRecord {
580            attempted_at: at(sent_ago, now),
581            next_at: at(sent_ago - 60, now),
582            sent_at: Some(at(sent_ago, now)),
583            state: Some(AgentState::Working),
584            stale_after_seconds: Some(180),
585            clock_skew_seconds: Some(0),
586            error: None,
587        }
588    }
589
590    fn rendered(lines: &[StatusLine]) -> Vec<String> {
591        lines.iter().map(|line| line.message.to_string()).collect()
592    }
593
594    #[test]
595    fn off_is_one_line_and_names_the_way_on() {
596        let lines = describe(false, None, Utc::now());
597        assert_eq!(lines.len(), 1);
598        assert!(lines[0].message.to_string().contains("kasl server pulse enable"));
599    }
600
601    #[test]
602    fn a_healthy_pulse_says_how_long_ago_and_what() {
603        let now = Utc::now();
604        let lines = describe(true, Some(&accepted(now, 42)), now);
605        assert_eq!(rendered(&lines), vec!["Pulse: on - the last one went 42 s ago (working)."]);
606        assert!(!lines[0].warning);
607    }
608
609    #[test]
610    fn a_failure_is_a_warning_and_keeps_the_last_arrival() {
611        let now = Utc::now();
612        let mut record = accepted(now, 400);
613        record.attempted_at = at(20, now);
614        record.next_at = at(-40, now);
615        record.error = Some("cannot reach kasl-server".to_string());
616
617        let lines = describe(true, Some(&record), now);
618        let text = rendered(&lines).join("\n");
619        assert!(lines[0].warning);
620        assert!(text.contains("20 s ago") && text.contains("cannot reach kasl-server"), "{text}");
621        assert!(text.contains("arrived went 6 min ago"), "{text}");
622        // Older than the server believes: it shows this person as offline,
623        // which is the part a person would not otherwise work out.
624        assert!(text.contains("offline"), "{text}");
625    }
626
627    #[test]
628    fn a_watcher_that_stopped_is_called_out() {
629        // The record of a watcher that was killed looks healthy forever - the
630        // last pulse did arrive - so silence is read off the time it meant to
631        // try next.
632        let now = Utc::now();
633        let lines = describe(true, Some(&accepted(now, 600)), now);
634        let text = rendered(&lines).join("\n");
635        assert!(text.contains("kasl watch"), "{text}");
636        assert!(text.contains("offline"), "{text}");
637    }
638
639    #[test]
640    fn a_punctual_watcher_is_not_called_silent() {
641        let now = Utc::now();
642        let mut record = accepted(now, 50);
643        record.next_at = at(SILENCE_GRACE_SECONDS, now);
644        assert_eq!(describe(true, Some(&record), now).len(), 1);
645    }
646
647    #[test]
648    fn a_skewed_clock_is_named_in_its_direction() {
649        let now = Utc::now();
650        let mut record = accepted(now, 5);
651        record.clock_skew_seconds = Some(45);
652        assert!(rendered(&describe(true, Some(&record), now)).join("\n").contains("45 s ahead"));
653
654        record.clock_skew_seconds = Some(-SKEW_WORTH_MENTIONING_SECONDS);
655        assert!(rendered(&describe(true, Some(&record), now)).join("\n").contains("behind"));
656
657        record.clock_skew_seconds = Some(SKEW_WORTH_MENTIONING_SECONDS - 1);
658        assert_eq!(describe(true, Some(&record), now).len(), 1);
659    }
660}