kasl/libs/monitor.rs
1//! The activity monitor: input events in, workdays and pauses out.
2//!
3//! A background thread listens to raw input via `rdev` and stamps
4//! `last_activity`; the async loop polls that timestamp and drives a
5//! two-state machine (Active / InPause) that writes workday and pause
6//! records.
7//!
8//! ```rust,no_run
9//! # async fn f() -> anyhow::Result<()> {
10//! use kasl::libs::config::MonitorConfig;
11//! use kasl::libs::monitor::Monitor;
12//!
13//! let config = MonitorConfig {
14//! pause_threshold: 120,
15//! activity_threshold: 60,
16//! poll_interval: 1000,
17//! min_pause_duration: 30,
18//! min_work_interval: 15,
19//! ..Default::default()
20//! };
21//!
22//! let mut monitor = Monitor::new(config)?;
23//! monitor.run().await?;
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::db::pauses::Pauses;
29use crate::db::workdays::Workdays;
30use crate::libs::config::MonitorConfig;
31use crate::libs::messages::Message;
32use crate::{msg_debug, msg_error, msg_info};
33use anyhow::Result;
34use chrono::{Local, NaiveDate};
35use rdev::{EventType, listen};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Arc, Mutex};
38use tokio::time::{self, Duration, Instant};
39use tracing::{Level, debug, instrument, span};
40
41/// The two states the loop moves between.
42#[derive(Debug, Clone, Copy, PartialEq)]
43enum State {
44 /// Input seen recently; workday end keeps advancing.
45 Active,
46
47 /// Inactivity crossed the threshold; waiting for input to close the pause.
48 InPause,
49}
50
51/// The monitor's moving parts: config, database handles, and the shared
52/// timestamps the input thread writes.
53pub struct Monitor {
54 /// Thresholds and intervals; changes require a restart to apply.
55 pub config: MonitorConfig,
56
57 /// Pause table handle.
58 pub pauses: Pauses,
59
60 /// Workday table handle.
61 pub workdays: Workdays,
62
63 /// When input was last seen; written by the listener thread.
64 pub last_activity: Arc<Mutex<Instant>>,
65
66 /// Start of the current sustained-activity streak, or `None`.
67 ///
68 /// Set on the first input after quiet, cleared when a pause begins or a
69 /// workday is created. Requiring the streak to outlast
70 /// `activity_threshold` keeps a stray mouse nudge from starting a
71 /// workday.
72 pub activity_start: Arc<Mutex<Option<Instant>>>,
73
74 /// Current loop state.
75 state: State,
76
77 /// Whether the loop is inside a pause, readable from outside it.
78 ///
79 /// The pulse needs to know whether someone is on a break right now, and
80 /// the loop is the only thing that does: the pauses table cannot say,
81 /// because a pause left open by a watcher that was killed mid-break stays
82 /// open there long after the person came back.
83 in_pause: Arc<AtomicBool>,
84}
85
86impl Monitor {
87 /// Opens the database handles and spawns the input listener thread.
88 ///
89 /// The listener updates `last_activity` on every keyboard/mouse event
90 /// and sets `activity_start` when a streak begins. Listener errors are
91 /// logged, not fatal - the loop keeps running without input data rather
92 /// than dying silently in the background.
93 ///
94 /// ```rust,no_run
95 /// # async fn f() -> anyhow::Result<()> {
96 /// use kasl::libs::config::MonitorConfig;
97 /// use kasl::libs::monitor::Monitor;
98 ///
99 /// let config = MonitorConfig::default();
100 /// let mut monitor = Monitor::new(config)?;
101 /// monitor.run().await?;
102 /// # Ok(())
103 /// # }
104 /// ```
105 #[instrument(skip(config))]
106 pub fn new(config: MonitorConfig) -> Result<Self> {
107 let span = span!(Level::INFO, "monitor_init");
108 let _enter = span.enter();
109
110 debug!("Initializing monitor with config: {:?}", config);
111
112 let pauses = Pauses::new()?;
113 let workdays = Workdays::new()?;
114
115 let last_activity = Arc::new(Mutex::new(Instant::now()));
116 let activity_start = Arc::new(Mutex::new(None));
117
118 let last_activity_clone = Arc::clone(&last_activity);
119 let activity_start_clone = Arc::clone(&activity_start);
120
121 // The listener blocks its thread, so it gets its own.
122 std::thread::spawn(move || {
123 if let Err(e) = listen(move |event| match event.event_type {
124 EventType::KeyPress(_)
125 | EventType::KeyRelease(_)
126 | EventType::ButtonPress(_)
127 | EventType::ButtonRelease(_)
128 | EventType::MouseMove { .. }
129 | EventType::Wheel { .. } => {
130 {
131 let mut last_activity = last_activity_clone.lock().unwrap();
132 *last_activity = Instant::now();
133 }
134
135 {
136 let mut activity_start = activity_start_clone.lock().unwrap();
137 // First input after quiet starts the sustained-activity streak.
138 if activity_start.is_none() {
139 *activity_start = Some(Instant::now());
140 }
141 }
142 }
143 }) {
144 msg_error!(Message::ErrorInRdevListener(format!("{:?}", e)));
145 }
146 });
147
148 Ok(Monitor {
149 config,
150 pauses,
151 workdays,
152 last_activity,
153 activity_start,
154 state: State::Active,
155 in_pause: Arc::new(AtomicBool::new(false)),
156 })
157 }
158
159 /// Runs the polling loop until the process is stopped.
160 ///
161 /// Each tick checks for recent input and dispatches on (state, activity):
162 /// Active+quiet may open a pause, InPause+input closes it, Active+input
163 /// keeps the workday going. Database errors inside a tick are logged and
164 /// the loop continues - a transient lock must not kill the daemon.
165 ///
166 /// `pause_threshold == 0` disables the loop entirely (returns at once).
167 ///
168 /// ```rust,no_run
169 /// # async fn f() -> anyhow::Result<()> {
170 /// use kasl::libs::config::MonitorConfig;
171 /// use kasl::libs::monitor::Monitor;
172 ///
173 /// let config = MonitorConfig {
174 /// poll_interval: 1000, // Check every second
175 /// pause_threshold: 120, // Pause after 2 minutes
176 /// activity_threshold: 30, // Workday starts after 30s
177 /// ..Default::default()
178 /// };
179 ///
180 /// let mut monitor = Monitor::new(config)?;
181 /// monitor.run().await?; // Runs indefinitely
182 /// # Ok(())
183 /// # }
184 /// ```
185 #[instrument(skip(self))]
186 pub async fn run(&mut self) -> Result<()> {
187 msg_info!(Message::MonitorStarted {
188 pause_threshold: self.config.pause_threshold,
189 poll_interval: self.config.poll_interval,
190 activity_threshold: self.config.activity_threshold,
191 });
192
193 // pause_threshold 0 means "no pause tracking".
194 if self.config.pause_threshold == 0 {
195 return Ok(());
196 }
197
198 loop {
199 let activity_detected = self.detect_activity();
200 let today = Local::now().date_naive();
201
202 match self.state {
203 State::Active if !activity_detected => {
204 if let Err(e) = self.handle_inactivity() {
205 msg_error!(Message::DatabaseOperationFailed {
206 operation: "handle_inactivity".to_string(),
207 error: e.to_string()
208 });
209 }
210 }
211 State::InPause if activity_detected => {
212 if let Err(e) = self.handle_return_from_pause() {
213 msg_error!(Message::DatabaseOperationFailed {
214 operation: "handle_return_from_pause".to_string(),
215 error: e.to_string()
216 });
217 }
218 }
219 State::Active if activity_detected => {
220 if let Err(e) = self.ensure_workday_started(today) {
221 msg_error!(Message::DatabaseOperationFailed {
222 operation: "ensure_workday_started".to_string(),
223 error: e.to_string()
224 });
225 }
226 }
227 // InPause with no activity: nothing to do.
228 _ => {}
229 }
230
231 time::sleep(Duration::from_millis(self.config.poll_interval)).await;
232 }
233 }
234
235 /// A handle that reads whether the loop is inside a pause.
236 ///
237 /// Taken before [`Monitor::run`] consumes the monitor's attention, and
238 /// read by the pulse on its own cadence.
239 pub fn pause_flag(&self) -> Arc<AtomicBool> {
240 Arc::clone(&self.in_pause)
241 }
242
243 /// Moves the loop into `state`, keeping [`Monitor::pause_flag`] in step.
244 fn enter(&mut self, state: State) {
245 self.state = state;
246 self.in_pause.store(state == State::InPause, Ordering::Relaxed);
247 }
248
249 /// True when input was seen within the last poll interval.
250 ///
251 /// ```rust,no_run
252 /// use kasl::libs::monitor::Monitor;
253 /// use kasl::libs::config::MonitorConfig;
254 ///
255 /// let monitor = Monitor::new(MonitorConfig::default())?;
256 ///
257 /// if monitor.detect_activity() {
258 /// println!("User is active");
259 /// } else {
260 /// println!("User appears inactive");
261 /// }
262 /// # Ok::<(), anyhow::Error>(())
263 /// ```
264 pub fn detect_activity(&self) -> bool {
265 let elapsed = self.last_activity.lock().unwrap().elapsed();
266 let is_active = elapsed < Duration::from_millis(self.config.poll_interval);
267
268 msg_debug!(format!(
269 "Activity check: elapsed={:?}, active={}, threshold={:?}",
270 elapsed,
271 is_active,
272 Duration::from_millis(self.config.poll_interval)
273 ));
274
275 is_active
276 }
277
278 /// Opens a pause once inactivity exceeds `pause_threshold`.
279 ///
280 /// The pause start is backdated by the threshold: the user stopped
281 /// working when the input stopped, not when the detector noticed.
282 /// Nothing is recorded before the workday exists - pre-work idle must
283 /// not become a pause ending seconds before the day starts.
284 ///
285 /// ```rust,no_run
286 /// use kasl::libs::config::MonitorConfig;
287 ///
288 /// // Called automatically by the monitoring loop; sensitivity comes
289 /// // from the config:
290 /// let config = MonitorConfig {
291 /// pause_threshold: 30, // Detect pauses after 30 seconds
292 /// ..Default::default()
293 /// };
294 /// ```
295 fn handle_inactivity(&mut self) -> Result<()> {
296 let idle_time = self.last_activity.lock().unwrap().elapsed();
297
298 if idle_time >= Duration::from_secs(self.config.pause_threshold) {
299 let today = Local::now().date_naive();
300 // Do not record pauses before the workday has started — otherwise
301 // pre-work idle creates a pause that ends seconds before workdays.start.
302 if self.workdays.fetch(today)?.is_none() {
303 return Ok(());
304 }
305
306 msg_info!(Message::PauseStarted);
307
308 // Backdate the start: the pause began when input stopped.
309 let pause_start_time = Local::now().naive_local() - chrono::Duration::seconds(self.config.pause_threshold as i64);
310 self.pauses.insert_start_with_time(pause_start_time)?;
311
312 self.enter(State::InPause);
313
314 // Reset the streak so a post-pause workday start requires
315 // sustained activity again.
316 *self.activity_start.lock().unwrap() = None;
317 }
318
319 Ok(())
320 }
321
322 /// Closes the open pause and returns to Active.
323 ///
324 /// ```rust,no_run
325 /// // Called automatically by the monitoring loop when input resumes;
326 /// // completes the record opened by handle_inactivity.
327 /// ```
328 fn handle_return_from_pause(&mut self) -> Result<()> {
329 msg_info!(Message::PauseEnded);
330 self.pauses.insert_end()?;
331 self.enter(State::Active);
332 Ok(())
333 }
334
335 /// Creates today's workday once sustained activity outlasts
336 /// `activity_threshold`; the streak tracker is cleared afterwards so
337 /// only one workday per date is created.
338 ///
339 /// ```rust,no_run
340 /// // Called automatically during the monitoring loop. With
341 /// // activity_threshold = 30: first input sets the streak start,
342 /// // and 30s of continued input creates the workday.
343 /// ```
344 pub fn ensure_workday_started(&mut self, today: NaiveDate) -> Result<()> {
345 let activity_start_time = {
346 let activity_start_guard = self.activity_start.lock().unwrap();
347 *activity_start_guard
348 };
349
350 if let Some(start_time) = activity_start_time {
351 let activity_duration = start_time.elapsed();
352
353 if activity_duration >= Duration::from_secs(self.config.activity_threshold) && self.workdays.fetch(today)?.is_none() {
354 match self.workdays.insert_start(today) {
355 Ok(()) => {
356 msg_info!(Message::WorkdayStarting(today.to_string()));
357 *self.activity_start.lock().unwrap() = None;
358 }
359 Err(e) => {
360 // Log and keep monitoring; one failed insert must
361 // not stop the daemon.
362 msg_error!(Message::WorkdayCreateFailed);
363 debug!("Workday creation error: {:?}", e);
364 }
365 }
366 }
367 }
368
369 Ok(())
370 }
371}