Skip to main content

kasl_server/
heartbeat.rs

1//! The agent's pulse: `POST /api/v1/agent/heartbeat`, and the live status the
2//! dashboard reads off it.
3//!
4//! Everything before this milestone answered about the past. A day arrives
5//! after it is over - or, for an open day, once in a while - and the manager's
6//! table said "last data 2 h ago", which is honest and not what the screen is
7//! for. The pulse is the one thing an agent can say about *now*: kasl is
8//! running on this machine, and the person it reports for is working, on a
9//! break, or not in a day at all.
10//!
11//! Three rules define it, all settled before the code:
12//!
13//! * **The agent claims the state; the server times it.** kasl knows whether
14//!   its watcher is inside a pause - the server would have to infer it from
15//!   rows that arrive minutes later. But staleness is measured against the
16//!   server's clock: a machine whose clock is a day off would otherwise look
17//!   permanently offline, or permanently alive.
18//! * **A missing pulse is not a state.** An agent too old to know this route,
19//!   or one that has been switched off, has no state at all, and the dashboard
20//!   says "unknown" rather than inventing `idle`. Reading silence as a claim
21//!   is the same defect the privacy work fixed at ingest: emptiness must not
22//!   lie (ADR 0011).
23//! * **The pulse says no more than the state.** Not the task, not the reason
24//!   for the break - those live in the day, under the privacy level that
25//!   governs them. A live feed of what someone is doing this minute is a
26//!   different product from a time tracker, and this one is not it (ADR 0014).
27
28use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
29use chrono::{DateTime, Duration, FixedOffset, Utc};
30use serde::{Deserialize, Serialize};
31use sqlx::PgPool;
32use uuid::Uuid;
33
34use crate::{app::AppState, auth::AuthenticatedAgent, error::ApiError};
35
36/// How often an agent is asked to report in.
37///
38/// Sent back on every pulse rather than configured in the agent: the interval
39/// and the staleness threshold below have to agree, and only one side can own
40/// that. An agent that guessed its own would eventually guess something the
41/// server calls offline.
42pub const INTERVAL_SECONDS: i64 = 60;
43
44/// How long a pulse stays believable, in seconds.
45///
46/// Three intervals: two missed pulses are a slow network or a laptop lid, and
47/// calling that "offline" would make the dashboard flicker at people who are
48/// working. The third miss is a real absence.
49pub const STALE_AFTER_SECONDS: i64 = INTERVAL_SECONDS * 3;
50
51/// How far ahead of the server an agent's own clock may be before its stamp is
52/// refused.
53///
54/// A stamp from the future would sit "fresh" for as long as the skew lasts,
55/// which is exactly how a stuck agent could look alive for a day. A minute of
56/// tolerance covers ordinary clock drift; beyond that the agent is told, so a
57/// person can fix the clock rather than trust a dashboard that is lying.
58const MAX_CLOCK_SKEW_SECONDS: i64 = 60;
59
60/// What an agent claims its person is doing. Mirrors the `agent_state` enum.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
62#[sqlx(type_name = "agent_state", rename_all = "lowercase")]
63#[serde(rename_all = "lowercase")]
64pub enum AgentState {
65    /// In a working day, and the watcher sees activity.
66    Working,
67    /// In a working day, inside a pause - a detected idle stretch or a break
68    /// the employee entered by hand.
69    Paused,
70    /// Running and reporting, but not in a working day: before the day starts,
71    /// after it is closed, on a weekend.
72    Idle,
73}
74
75/// What an agent sends.
76#[derive(Debug, Deserialize)]
77pub struct Pulse {
78    pub state: AgentState,
79    /// When the agent observed this, with its own UTC offset - the same
80    /// convention every timestamp in this API follows (ADR 0003).
81    ///
82    /// Sent rather than left to the server so a pulse delayed by a slow link
83    /// is not read as a fresher observation than it is.
84    pub at: DateTime<FixedOffset>,
85}
86
87/// What the agent is told back.
88///
89/// The agent gets to see the server's reading of its own pulse: kasl can show
90/// "the server thinks you are offline" in the CLI, where the employee already
91/// is, instead of leaving them to discover it on a dashboard they cannot see.
92#[derive(Debug, Serialize)]
93pub struct Accepted {
94    /// Seconds until the agent should report again.
95    pub interval_seconds: i64,
96    /// After how many seconds of silence the server stops believing a pulse.
97    pub stale_after_seconds: i64,
98    /// The state as recorded, echoed so a mismatch is visible at the agent.
99    pub state: AgentState,
100    /// How far the agent's clock is from the server's, in seconds, positive
101    /// when the agent is ahead. Reported rather than silently corrected: a
102    /// clock that is minutes out makes every hour this server stores wrong,
103    /// and only the machine's owner can fix it.
104    pub clock_skew_seconds: i64,
105}
106
107/// Records a pulse.
108pub async fn beat(State(state): State<AppState>, agent: AuthenticatedAgent, Json(pulse): Json<Pulse>) -> Result<impl IntoResponse, ApiError> {
109    let now = Utc::now();
110    let at = pulse.at.with_timezone(&Utc);
111    let skew = (at - now).num_seconds();
112
113    if skew > MAX_CLOCK_SKEW_SECONDS {
114        // Refused rather than clamped. Clamping would store a plausible stamp
115        // and leave the real fault - a machine whose clock is wrong, and whose
116        // uploaded days are therefore wrong too - invisible at both ends.
117        return Err(ApiError::bad_request(format!(
118            "the pulse is stamped {skew} s ahead of this server; check the machine's clock"
119        )));
120    }
121
122    sqlx::query("UPDATE agents SET heartbeat_state = $2, heartbeat_at = $3, heartbeat_received_at = now() WHERE id = $1")
123        .bind(agent.agent_id)
124        .bind(pulse.state)
125        .bind(at)
126        .execute(&state.pool)
127        .await?;
128
129    Ok((
130        StatusCode::ACCEPTED,
131        Json(Accepted {
132            interval_seconds: INTERVAL_SECONDS,
133            stale_after_seconds: STALE_AFTER_SECONDS,
134            state: pulse.state,
135            clock_skew_seconds: skew,
136        }),
137    ))
138}
139
140/// What the dashboard shows for one person, right now.
141///
142/// Deliberately wider than [`AgentState`]: `offline` and `unknown` are not
143/// things an agent can claim, they are what the server concludes from silence.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "lowercase")]
146pub enum LiveStatus {
147    Working,
148    Paused,
149    Idle,
150    /// A pulse was received once, but not recently enough to believe.
151    Offline,
152    /// No pulse has ever arrived: no agent, an agent switched off since before
153    /// this server started asking, or a kasl too old to know the route.
154    Unknown,
155}
156
157impl LiveStatus {
158    /// Resolves a stored pulse into what the dashboard shows.
159    ///
160    /// The one place the threshold is applied. `received` is the server's own
161    /// stamp on purpose - see the module note on clocks.
162    pub fn resolve(state: Option<AgentState>, received: Option<DateTime<Utc>>, now: DateTime<Utc>) -> Self {
163        let (Some(state), Some(received)) = (state, received) else {
164            return Self::Unknown;
165        };
166        if now - received > Duration::seconds(STALE_AFTER_SECONDS) {
167            return Self::Offline;
168        }
169        match state {
170            AgentState::Working => Self::Working,
171            AgentState::Paused => Self::Paused,
172            AgentState::Idle => Self::Idle,
173        }
174    }
175}
176
177/// One person's live row.
178#[derive(Debug, Serialize)]
179pub struct Live {
180    pub user_id: Uuid,
181    /// What the dashboard should show, with the staleness threshold applied.
182    pub status: LiveStatus,
183    /// Seconds since the server received that pulse. `None` when there is
184    /// none - the dashboard prints "unknown", not "0 seconds ago".
185    pub since_received: Option<i64>,
186}
187
188/// Loads the live status of everyone the reader may see.
189///
190/// Takes the visibility clause from the caller rather than embedding one:
191/// there is exactly one rule for who may see whom ([`crate::admin::VISIBLE_USERS`]),
192/// and a second copy of it here would be a second chance for one of them to
193/// widen.
194pub async fn load(pool: &PgPool, visible_users: &str, is_admin: bool, reader: Uuid) -> Result<Vec<Live>, ApiError> {
195    // `AssertSqlSafe` because the only interpolation is `visible_users`, a
196    // constant in `admin`; every value from the request is bound.
197    let rows: Vec<(Uuid, Option<AgentState>, Option<DateTime<Utc>>)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
198        "SELECT u.id, h.heartbeat_state, h.heartbeat_received_at
199         FROM users u
200         LEFT JOIN LATERAL (
201             -- The freshest pulse among this person's live agents. Someone
202             -- with a desktop and a laptop is working if either says so, and
203             -- a revoked agent's last words are not evidence of anything.
204             SELECT a.heartbeat_state, a.heartbeat_received_at
205             FROM agents a
206             WHERE a.user_id = u.id AND a.revoked_at IS NULL AND a.heartbeat_received_at IS NOT NULL
207             ORDER BY a.heartbeat_received_at DESC
208             LIMIT 1
209         ) AS h ON true
210         WHERE u.active AND {visible_users}
211         ORDER BY u.display_name, u.email"
212    )))
213    .bind(is_admin)
214    .bind(reader)
215    .fetch_all(pool)
216    .await?;
217
218    // The threshold is applied here rather than in SQL so `resolve` is the
219    // single definition of "offline" that the unit tests can reach.
220    let now = Utc::now();
221    Ok(rows
222        .into_iter()
223        .map(|(user_id, state, received)| Live {
224            user_id,
225            status: LiveStatus::resolve(state, received, now),
226            since_received: received.map(|received| (now - received).num_seconds().max(0)),
227        })
228        .collect())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn seconds_ago(now: DateTime<Utc>, seconds: i64) -> Option<DateTime<Utc>> {
236        Some(now - Duration::seconds(seconds))
237    }
238
239    #[test]
240    fn the_wire_names_are_the_contract() {
241        // Renaming any of these is a breaking API change for kasl agents and
242        // for the web UI, so it has to fail here rather than at a puzzled
243        // client (the same guard `model` keeps over roles).
244        assert_eq!(serde_json::to_string(&AgentState::Working).unwrap(), r#""working""#);
245        assert_eq!(serde_json::to_string(&AgentState::Paused).unwrap(), r#""paused""#);
246        assert_eq!(serde_json::to_string(&AgentState::Idle).unwrap(), r#""idle""#);
247        assert_eq!(serde_json::to_string(&LiveStatus::Offline).unwrap(), r#""offline""#);
248        assert_eq!(serde_json::to_string(&LiveStatus::Unknown).unwrap(), r#""unknown""#);
249    }
250
251    #[test]
252    fn a_state_round_trips_through_json() {
253        for state in [AgentState::Working, AgentState::Paused, AgentState::Idle] {
254            let json = serde_json::to_string(&state).unwrap();
255            assert_eq!(serde_json::from_str::<AgentState>(&json).unwrap(), state);
256        }
257    }
258
259    #[test]
260    fn a_fresh_pulse_is_shown_as_claimed() {
261        let now = Utc::now();
262        for (state, expected) in [
263            (AgentState::Working, LiveStatus::Working),
264            (AgentState::Paused, LiveStatus::Paused),
265            (AgentState::Idle, LiveStatus::Idle),
266        ] {
267            assert_eq!(LiveStatus::resolve(Some(state), seconds_ago(now, 5), now), expected);
268        }
269    }
270
271    #[test]
272    fn a_pulse_survives_two_missed_intervals() {
273        // The reason the threshold is three intervals and not one: a laptop
274        // lid or a slow link must not paint someone who is working as gone.
275        let now = Utc::now();
276        let two_missed = INTERVAL_SECONDS * 2 + 5;
277        assert_eq!(
278            LiveStatus::resolve(Some(AgentState::Working), seconds_ago(now, two_missed), now),
279            LiveStatus::Working
280        );
281    }
282
283    #[test]
284    fn a_stale_pulse_is_offline_whatever_it_claimed() {
285        // The defect this guards: showing the last claim forever. An agent
286        // killed mid-day would leave "working" on the dashboard indefinitely,
287        // which is worse than no status at all - it is a wrong one.
288        let now = Utc::now();
289        let stale = seconds_ago(now, STALE_AFTER_SECONDS + 1);
290        for state in [AgentState::Working, AgentState::Paused, AgentState::Idle] {
291            assert_eq!(LiveStatus::resolve(Some(state), stale, now), LiveStatus::Offline);
292        }
293    }
294
295    #[test]
296    fn silence_is_unknown_rather_than_idle() {
297        // An agent that never sent a pulse and an agent that says "not in a
298        // day" are different facts, and only one of them is evidence about
299        // the person. Collapsing them would tell a manager that everyone
300        // running an older kasl has stopped working.
301        let now = Utc::now();
302        assert_eq!(LiveStatus::resolve(None, None, now), LiveStatus::Unknown);
303        // A state without a receipt cannot be aged, so it is not believable
304        // either - this pairing should be impossible in the schema, and if it
305        // ever happens it must not read as a live claim.
306        assert_eq!(LiveStatus::resolve(Some(AgentState::Working), None, now), LiveStatus::Unknown);
307        assert_eq!(LiveStatus::resolve(None, seconds_ago(now, 1), now), LiveStatus::Unknown);
308    }
309
310    #[test]
311    fn the_interval_leaves_room_for_a_missed_pulse() {
312        // The agent is told both numbers and must be able to miss one without
313        // being called offline; a threshold at or below the interval would
314        // make that impossible however punctual the agent is.
315        const { assert!(STALE_AFTER_SECONDS > INTERVAL_SECONDS, "an agent gets no margin at all") };
316    }
317}