use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, AgentState, KaslServer, Pulse, UploadError};
use crate::db::server_pulse::{PulseRecord, ServerPulse};
use crate::db::workdays::{Workday, Workdays};
use crate::libs::config::{Config, KaslServerConfig};
use crate::libs::messages::Message;
use crate::libs::secret::Secret;
use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tracing::{debug, warn};
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(60);
pub const REFUSED_RETRY: Duration = Duration::from_secs(5 * 60);
const INTERVAL_BOUNDS: (u64, u64) = (10, 15 * 60);
const TICK: Duration = Duration::from_secs(5);
const RECORD_RETRY_GAP: Duration = Duration::from_secs(10);
const SILENCE_GRACE_SECONDS: i64 = 30;
const SKEW_WORTH_MENTIONING_SECONDS: i64 = 10;
pub fn presence(in_pause: bool, today: Option<&Workday>) -> AgentState {
match today {
Some(day) if day.end.is_none() => {
if in_pause {
AgentState::Paused
} else {
AgentState::Working
}
}
_ => AgentState::Idle,
}
}
pub fn interval_from(seconds: i64) -> Duration {
let (low, high) = INTERVAL_BOUNDS;
Duration::from_secs((seconds.max(0) as u64).clamp(low, high))
}
#[derive(Debug, Default, Clone)]
pub struct Cadence {
next_at: Option<Instant>,
attempted_at: Option<Instant>,
sent_state: Option<AgentState>,
holding: bool,
}
impl Cadence {
pub fn is_due(&self, now: Instant, state: AgentState, record_missing: bool) -> bool {
let Some(next_at) = self.next_at else {
return true;
};
if now >= next_at {
return true;
}
if !self.holding && self.sent_state != Some(state) {
return true;
}
record_missing && self.attempted_at.is_none_or(|at| now.duration_since(at) >= RECORD_RETRY_GAP)
}
pub fn sent(&mut self, now: Instant, state: AgentState, wait: Duration) {
self.attempted_at = Some(now);
self.next_at = Some(now + wait);
self.sent_state = Some(state);
self.holding = false;
}
pub fn failed(&mut self, now: Instant, wait: Duration) {
self.attempted_at = Some(now);
self.next_at = Some(now + wait);
self.holding = true;
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Beat {
Accepted(AgentState),
Deferred(String),
Refused(String),
}
pub async fn beat(client: &KaslServer, token: &str, state: AgentState, interval: Duration, store: &mut ServerPulse) -> Result<(Beat, Duration)> {
let at = Local::now().fixed_offset();
let now = at.with_timezone(&Utc);
let (beat, wait) = match client.heartbeat(token, &Pulse { state, at }).await {
Ok(accepted) => {
let wait = interval_from(accepted.interval_seconds);
store.record_accepted(now, now + wait, accepted.state, accepted.stale_after_seconds, accepted.clock_skew_seconds)?;
(Beat::Accepted(accepted.state), wait)
}
Err(UploadError::Retryable { message }) => {
store.record_failed(now, now + interval, &message)?;
(Beat::Deferred(message), interval)
}
Err(UploadError::Rejected { status, message }) => {
let message = format!("the server refused the pulse ({}): {}", status, message);
store.record_failed(now, now + REFUSED_RETRY, &message)?;
(Beat::Refused(message), REFUSED_RETRY)
}
};
Ok((beat, wait))
}
type TokenSource = Box<dyn Fn() -> Option<String> + Send + Sync>;
pub struct Pulser {
in_pause: Arc<AtomicBool>,
token: TokenSource,
cadence: Cadence,
interval: Duration,
client: Option<(KaslServerConfig, KaslServer)>,
workdays: Option<Workdays>,
store: Option<ServerPulse>,
last_error: Option<String>,
}
impl Pulser {
pub fn new(in_pause: Arc<AtomicBool>) -> Self {
Self::with_token(in_pause, Box::new(|| Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached()))
}
pub fn with_token(in_pause: Arc<AtomicBool>, token: TokenSource) -> Self {
Self {
in_pause,
token,
cadence: Cadence::default(),
interval: DEFAULT_INTERVAL,
client: None,
workdays: None,
store: None,
last_error: None,
}
}
pub async fn tick(&mut self) -> Result<Option<Beat>> {
let config = Config::read()?;
let Some(server) = config.kasl_server.filter(|server| server.pulse) else {
self.cadence = Cadence::default();
self.client = None;
return Ok(None);
};
let state = self.claim()?;
let record_missing = self.store()?.last()?.is_none();
let now = Instant::now();
if !self.cadence.is_due(now, state, record_missing) {
return Ok(None);
}
let client = match self.client_for(&server) {
Ok(client) => client,
Err(error) => return self.give_up(now, error.to_string()).map(Some),
};
let Some(token) = (self.token)() else {
return self
.give_up(now, "no agent token is stored - run `kasl server connect` again".to_string())
.map(Some);
};
let interval = self.interval;
let outcome = beat(&client, &token, state, interval, self.store()?).await;
let (beat, wait) = match outcome {
Ok(result) => result,
Err(error) => {
self.cadence.failed(now, interval);
self.store = None;
return Err(error);
}
};
match &beat {
Beat::Accepted(_) => {
self.interval = wait;
self.cadence.sent(now, state, wait);
if self.last_error.take().is_some() {
debug!("Pulse: arriving again");
}
}
Beat::Deferred(reason) | Beat::Refused(reason) => {
self.cadence.failed(now, wait);
self.note_failure(reason);
}
}
Ok(Some(beat))
}
fn claim(&mut self) -> Result<AgentState> {
let today = Local::now().date_naive();
let day = match self.workdays()?.fetch(today) {
Ok(day) => day,
Err(error) => {
self.workdays = None;
return Err(error);
}
};
Ok(presence(self.in_pause.load(Ordering::Relaxed), day.as_ref()))
}
fn give_up(&mut self, now: Instant, reason: String) -> Result<Beat> {
self.cadence.failed(now, REFUSED_RETRY);
let at = Utc::now();
self.store()?.record_failed(at, at + chrono::Duration::from_std(REFUSED_RETRY)?, &reason)?;
self.note_failure(&reason);
Ok(Beat::Refused(reason))
}
fn note_failure(&mut self, reason: &str) {
if self.last_error.as_deref() != Some(reason) {
warn!("Pulse did not arrive: {}", reason);
self.last_error = Some(reason.to_string());
}
}
fn client_for(&mut self, server: &KaslServerConfig) -> Result<KaslServer> {
if let Some((built_for, client)) = &self.client
&& built_for == server
{
return Ok(client.clone());
}
let client = KaslServer::new(server)?;
self.client = Some((server.clone(), client.clone()));
Ok(client)
}
fn workdays(&mut self) -> Result<&mut Workdays> {
if self.workdays.is_none() {
self.workdays = Some(Workdays::new()?);
}
Ok(self.workdays.as_mut().expect("opened above"))
}
fn store(&mut self) -> Result<&mut ServerPulse> {
if self.store.is_none() {
self.store = Some(ServerPulse::new()?);
}
Ok(self.store.as_mut().expect("opened above"))
}
}
pub async fn run(in_pause: Arc<AtomicBool>) {
let mut pulser = Pulser::new(in_pause);
loop {
match pulser.tick().await {
Ok(Some(Beat::Accepted(state))) => debug!("Pulse sent: {}", state),
Ok(_) => {}
Err(error) => warn!("Pulse: {}", error),
}
tokio::time::sleep(TICK).await;
}
}
#[derive(Debug)]
pub struct StatusLine {
pub warning: bool,
pub message: Message,
}
impl StatusLine {
fn info(message: Message) -> Self {
Self { warning: false, message }
}
fn warning(message: Message) -> Self {
Self { warning: true, message }
}
}
pub fn describe(enabled: bool, record: Option<&PulseRecord>, now: DateTime<Utc>) -> Vec<StatusLine> {
if !enabled {
return vec![StatusLine::info(Message::PulseOff)];
}
let Some(record) = record else {
return vec![StatusLine::info(Message::PulseNothingYet)];
};
let ago = |at: DateTime<Utc>| format_ago((now - at).num_seconds());
let mut lines = Vec::new();
match &record.error {
None => lines.push(StatusLine::info(Message::PulseLastSent {
ago: ago(record.sent_at.unwrap_or(record.attempted_at)),
state: record.state.map(|state| state.to_string()),
})),
Some(error) => {
lines.push(StatusLine::warning(Message::PulseFailing {
ago: ago(record.attempted_at),
error: error.clone(),
}));
if let Some(sent_at) = record.sent_at {
lines.push(StatusLine::info(Message::PulseLastArrived(ago(sent_at))));
}
}
}
if (now - record.next_at).num_seconds() > SILENCE_GRACE_SECONDS {
lines.push(StatusLine::warning(Message::PulseSilent(ago(record.next_at))));
}
if let (Some(sent_at), Some(stale_after)) = (record.sent_at, record.stale_after_seconds)
&& (now - sent_at).num_seconds() > stale_after
{
lines.push(StatusLine::warning(Message::PulseShownOffline(format_ago(stale_after))));
}
if let Some(skew) = record.clock_skew_seconds
&& skew.abs() >= SKEW_WORTH_MENTIONING_SECONDS
{
lines.push(StatusLine::warning(Message::PulseClockSkew(skew)));
}
lines
}
pub fn format_ago(seconds: i64) -> String {
let seconds = seconds.max(0);
match seconds {
0..60 => format!("{} s", seconds),
60..3600 => format!("{} min", seconds / 60),
3600..86400 => match (seconds % 3600) / 60 {
0 => format!("{} h", seconds / 3600),
minutes => format!("{} h {} min", seconds / 3600, minutes),
},
_ => format!("{} d", seconds / 86400),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, NaiveDateTime};
fn day(end: Option<NaiveDateTime>) -> Workday {
let date = NaiveDate::from_ymd_opt(2026, 10, 2).unwrap();
Workday {
id: 1,
date,
start: date.and_hms_opt(9, 0, 0).unwrap(),
end,
}
}
#[test]
fn an_open_day_is_working_or_paused() {
let open = day(None);
assert_eq!(presence(false, Some(&open)), AgentState::Working);
assert_eq!(presence(true, Some(&open)), AgentState::Paused);
}
#[test]
fn no_day_or_a_closed_one_is_idle_whatever_the_monitor_says() {
let closed = day(Some(NaiveDate::from_ymd_opt(2026, 10, 2).unwrap().and_hms_opt(18, 0, 0).unwrap()));
for in_pause in [false, true] {
assert_eq!(presence(in_pause, None), AgentState::Idle);
assert_eq!(presence(in_pause, Some(&closed)), AgentState::Idle);
}
}
#[test]
fn a_server_interval_is_taken_within_bounds() {
assert_eq!(interval_from(60), Duration::from_secs(60));
assert_eq!(interval_from(0), Duration::from_secs(INTERVAL_BOUNDS.0));
assert_eq!(interval_from(-5), Duration::from_secs(INTERVAL_BOUNDS.0));
assert_eq!(interval_from(3600), Duration::from_secs(INTERVAL_BOUNDS.1));
}
#[test]
fn the_first_pulse_goes_at_once() {
assert!(Cadence::default().is_due(Instant::now(), AgentState::Working, false));
}
#[test]
fn an_unchanged_claim_waits_for_the_interval() {
let start = Instant::now();
let mut cadence = Cadence::default();
cadence.sent(start, AgentState::Working, Duration::from_secs(60));
assert!(!cadence.is_due(start + Duration::from_secs(59), AgentState::Working, false));
assert!(cadence.is_due(start + Duration::from_secs(60), AgentState::Working, false));
}
#[test]
fn a_changed_claim_goes_without_waiting() {
let start = Instant::now();
let mut cadence = Cadence::default();
cadence.sent(start, AgentState::Working, Duration::from_secs(60));
assert!(cadence.is_due(start + Duration::from_secs(5), AgentState::Paused, false));
}
#[test]
fn after_a_failure_even_a_change_waits() {
let start = Instant::now();
let mut cadence = Cadence::default();
cadence.sent(start, AgentState::Working, Duration::from_secs(60));
cadence.failed(start + Duration::from_secs(60), Duration::from_secs(60));
assert!(!cadence.is_due(start + Duration::from_secs(70), AgentState::Paused, false));
assert!(cadence.is_due(start + Duration::from_secs(120), AgentState::Paused, false));
}
#[test]
fn a_cleared_record_asks_again_but_not_every_tick() {
let start = Instant::now();
let mut cadence = Cadence::default();
cadence.failed(start, REFUSED_RETRY);
assert!(!cadence.is_due(start + Duration::from_secs(5), AgentState::Working, true));
assert!(cadence.is_due(start + RECORD_RETRY_GAP, AgentState::Working, true));
assert!(!cadence.is_due(start + RECORD_RETRY_GAP, AgentState::Working, false));
}
#[test]
fn spans_read_coarser_as_they_grow() {
assert_eq!(format_ago(0), "0 s");
assert_eq!(format_ago(42), "42 s");
assert_eq!(format_ago(60), "1 min");
assert_eq!(format_ago(359), "5 min");
assert_eq!(format_ago(3600), "1 h");
assert_eq!(format_ago(7500), "2 h 5 min");
assert_eq!(format_ago(3 * 86400 + 5), "3 d");
assert_eq!(format_ago(-30), "0 s");
}
fn at(seconds_before: i64, now: DateTime<Utc>) -> DateTime<Utc> {
now - chrono::Duration::seconds(seconds_before)
}
fn accepted(now: DateTime<Utc>, sent_ago: i64) -> PulseRecord {
PulseRecord {
attempted_at: at(sent_ago, now),
next_at: at(sent_ago - 60, now),
sent_at: Some(at(sent_ago, now)),
state: Some(AgentState::Working),
stale_after_seconds: Some(180),
clock_skew_seconds: Some(0),
error: None,
}
}
fn rendered(lines: &[StatusLine]) -> Vec<String> {
lines.iter().map(|line| line.message.to_string()).collect()
}
#[test]
fn off_is_one_line_and_names_the_way_on() {
let lines = describe(false, None, Utc::now());
assert_eq!(lines.len(), 1);
assert!(lines[0].message.to_string().contains("kasl server pulse enable"));
}
#[test]
fn a_healthy_pulse_says_how_long_ago_and_what() {
let now = Utc::now();
let lines = describe(true, Some(&accepted(now, 42)), now);
assert_eq!(rendered(&lines), vec!["Pulse: on - the last one went 42 s ago (working)."]);
assert!(!lines[0].warning);
}
#[test]
fn a_failure_is_a_warning_and_keeps_the_last_arrival() {
let now = Utc::now();
let mut record = accepted(now, 400);
record.attempted_at = at(20, now);
record.next_at = at(-40, now);
record.error = Some("cannot reach kasl-server".to_string());
let lines = describe(true, Some(&record), now);
let text = rendered(&lines).join("\n");
assert!(lines[0].warning);
assert!(text.contains("20 s ago") && text.contains("cannot reach kasl-server"), "{text}");
assert!(text.contains("arrived went 6 min ago"), "{text}");
assert!(text.contains("offline"), "{text}");
}
#[test]
fn a_watcher_that_stopped_is_called_out() {
let now = Utc::now();
let lines = describe(true, Some(&accepted(now, 600)), now);
let text = rendered(&lines).join("\n");
assert!(text.contains("kasl watch"), "{text}");
assert!(text.contains("offline"), "{text}");
}
#[test]
fn a_punctual_watcher_is_not_called_silent() {
let now = Utc::now();
let mut record = accepted(now, 50);
record.next_at = at(SILENCE_GRACE_SECONDS, now);
assert_eq!(describe(true, Some(&record), now).len(), 1);
}
#[test]
fn a_skewed_clock_is_named_in_its_direction() {
let now = Utc::now();
let mut record = accepted(now, 5);
record.clock_skew_seconds = Some(45);
assert!(rendered(&describe(true, Some(&record), now)).join("\n").contains("45 s ahead"));
record.clock_skew_seconds = Some(-SKEW_WORTH_MENTIONING_SECONDS);
assert!(rendered(&describe(true, Some(&record), now)).join("\n").contains("behind"));
record.clock_skew_seconds = Some(SKEW_WORTH_MENTIONING_SECONDS - 1);
assert_eq!(describe(true, Some(&record), now).len(), 1);
}
}