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