Skip to main content

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}