1use 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
44pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(60);
46
47pub const REFUSED_RETRY: Duration = Duration::from_secs(5 * 60);
54
55const INTERVAL_BOUNDS: (u64, u64) = (10, 15 * 60);
60
61const TICK: Duration = Duration::from_secs(5);
67
68const RECORD_RETRY_GAP: Duration = Duration::from_secs(10);
74
75const SILENCE_GRACE_SECONDS: i64 = 30;
78
79const SKEW_WORTH_MENTIONING_SECONDS: i64 = 10;
85
86pub 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
104pub 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#[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 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 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 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#[derive(Debug, Clone, PartialEq)]
162pub enum Beat {
163 Accepted(AgentState),
165
166 Deferred(String),
168
169 Refused(String),
171}
172
173pub 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
202type TokenSource = Box<dyn Fn() -> Option<String> + Send + Sync>;
204
205pub 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 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 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 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 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 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 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 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 self.workdays = None;
313 return Err(error);
314 }
315 };
316 Ok(presence(self.in_pause.load(Ordering::Relaxed), day.as_ref()))
317 }
318
319 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 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
362pub 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#[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
396pub 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
448pub 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 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 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 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 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 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 assert!(text.contains("offline"), "{text}");
625 }
626
627 #[test]
628 fn a_watcher_that_stopped_is_called_out() {
629 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}