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}