Commit 146b67f808
Verified · cmc
crates/signald/src/history.rs +121 −1
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | use std::path::Path; |
| 27 | 27 | use std::time::Duration; |
| 28 | 28 | |
| 29 | use rusqlite::{params, Connection}; | |
| 29 | use rusqlite::{params, Connection, OpenFlags}; | |
| 30 | 30 | use signal_schema::{wire, Signal}; |
| 31 | 31 | |
| 32 | 32 | use crate::now_millis; |
| @@ -64,6 +64,41 @@ impl History { | ||
| 64 | 64 | Ok(h) |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | /// Open an existing database without writing to it. | |
| 68 | /// | |
| 69 | /// [`History::open`] prunes, creates and migrates. A reader that polls — | |
| 70 | /// the menu-bar pet asks every few seconds when activity last happened — | |
| 71 | /// must not mutate the daemon's store as a side effect of looking at it. | |
| 72 | /// Returns an error if the database does not exist yet. | |
| 73 | pub fn open_read_only(path: &Path) -> rusqlite::Result<History> { | |
| 74 | let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; | |
| 75 | Ok(History { | |
| 76 | conn, | |
| 77 | retention_ms: None, | |
| 78 | since_prune: 0, | |
| 79 | }) | |
| 80 | } | |
| 81 | ||
| 82 | /// When activity was last seen, as unix millis, or `None` if there is | |
| 83 | /// none on record. | |
| 84 | /// | |
| 85 | /// Activity is a keystroke rate or a commit-count above zero. Deliberately | |
| 86 | /// not `SessionSeconds`: a shell left open is not attention, and counting | |
| 87 | /// it would let a forgotten terminal stand in for a person indefinitely. | |
| 88 | /// Deliberately not the hardware signals either — a sleeping machine still | |
| 89 | /// reports a battery percentage. | |
| 90 | pub fn last_activity_ms(&self) -> rusqlite::Result<Option<u64>> { | |
| 91 | let ts: Option<i64> = self.conn.query_row( | |
| 92 | "SELECT MAX(ts) FROM signals WHERE name IN (?1, ?2) AND value > 0.0", | |
| 93 | params![ | |
| 94 | signal_schema::SignalName::KeysPerMin.to_u8(), | |
| 95 | signal_schema::SignalName::CommitsToday.to_u8(), | |
| 96 | ], | |
| 97 | |row| row.get(0), | |
| 98 | )?; | |
| 99 | Ok(ts.map(|t| t as u64)) | |
| 100 | } | |
| 101 | ||
| 67 | 102 | /// An ephemeral in-memory store (used by tests). No retention. |
| 68 | 103 | pub fn open_in_memory() -> rusqlite::Result<History> { |
| 69 | 104 | Self::init(Connection::open_in_memory()?, None) |
| @@ -269,6 +304,91 @@ mod tests { | ||
| 269 | 304 | let _ = std::fs::remove_dir_all(&dir); |
| 270 | 305 | } |
| 271 | 306 | |
| 307 | /// Activity is what the menu-bar pet lives on, so what counts is exact. | |
| 308 | #[test] | |
| 309 | fn last_activity_is_typing_and_commits_only() { | |
| 310 | let mut h = History::open_in_memory().unwrap(); | |
| 311 | // A shell sitting open, and a machine reporting hardware while asleep. | |
| 312 | // Neither is a person showing up. | |
| 313 | for (name, value, ts) in [ | |
| 314 | (SignalName::SessionSeconds, 9000.0, 500_u64), | |
| 315 | (SignalName::BatteryPct, 80.0, 600), | |
| 316 | (SignalName::CpuLoad, 0.4, 700), | |
| 317 | ] { | |
| 318 | h.record(&Signal { ts, ..sig(name, value, None) }).unwrap(); | |
| 319 | } | |
| 320 | assert_eq!(h.last_activity_ms().unwrap(), None, "none of that is attention"); | |
| 321 | ||
| 322 | h.record(&Signal { ts: 1_000, ..sig(SignalName::KeysPerMin, 30.0, None) }) | |
| 323 | .unwrap(); | |
| 324 | assert_eq!(h.last_activity_ms().unwrap(), Some(1_000)); | |
| 325 | ||
| 326 | h.record(&Signal { ts: 2_000, ..sig(SignalName::CommitsToday, 3.0, None) }) | |
| 327 | .unwrap(); | |
| 328 | assert_eq!(h.last_activity_ms().unwrap(), Some(2_000), "the most recent wins"); | |
| 329 | } | |
| 330 | ||
| 331 | /// A quiet day publishes zeroes rather than nothing, so a zero must not | |
| 332 | /// read as activity or the pet would never age. | |
| 333 | #[test] | |
| 334 | fn a_zero_is_not_activity() { | |
| 335 | let mut h = History::open_in_memory().unwrap(); | |
| 336 | h.record(&Signal { ts: 1_000, ..sig(SignalName::KeysPerMin, 0.0, None) }) | |
| 337 | .unwrap(); | |
| 338 | h.record(&Signal { ts: 1_100, ..sig(SignalName::CommitsToday, 0.0, None) }) | |
| 339 | .unwrap(); | |
| 340 | assert_eq!(h.last_activity_ms().unwrap(), None); | |
| 341 | } | |
| 342 | ||
| 343 | /// The reason this exists: `open` prunes on the way in. A pet polling every | |
| 344 | /// five seconds must not be quietly deleting the daemon's history. | |
| 345 | #[test] | |
| 346 | fn read_only_open_does_not_prune() { | |
| 347 | let dir = std::env::temp_dir().join(format!("signald-ro-{}", std::process::id())); | |
| 348 | let _ = std::fs::remove_dir_all(&dir); | |
| 349 | std::fs::create_dir_all(&dir).unwrap(); | |
| 350 | let db = dir.join("history.db"); | |
| 351 | ||
| 352 | { | |
| 353 | // Retention of an hour, and a row far older than that. | |
| 354 | let mut h = | |
| 355 | History::open_with_retention(&db, std::time::Duration::from_secs(3600)).unwrap(); | |
| 356 | h.record(&Signal { ts: 1, ..sig(SignalName::KeysPerMin, 5.0, None) }) | |
| 357 | .unwrap(); | |
| 358 | } | |
| 359 | ||
| 360 | let ro = History::open_read_only(&db).unwrap(); | |
| 361 | assert_eq!(ro.last_activity_ms().unwrap(), Some(1), "the old row is readable"); | |
| 362 | drop(ro); | |
| 363 | ||
| 364 | // Still there: a read-only open left it alone. A normal open would have | |
| 365 | // pruned it on the way in. | |
| 366 | let ro = History::open_read_only(&db).unwrap(); | |
| 367 | assert_eq!( | |
| 368 | ro.conn | |
| 369 | .query_row("SELECT COUNT(*) FROM signals", [], |r| r.get::<_, i64>(0)) | |
| 370 | .unwrap(), | |
| 371 | 1, | |
| 372 | "read-only open must not have deleted anything" | |
| 373 | ); | |
| 374 | drop(ro); | |
| 375 | ||
| 376 | // And prove the contrast: a normal open does prune it. | |
| 377 | let h = History::open_with_retention(&db, std::time::Duration::from_secs(3600)).unwrap(); | |
| 378 | assert_eq!(h.recent(10).unwrap().len(), 0, "a writing open prunes"); | |
| 379 | let _ = std::fs::remove_dir_all(&dir); | |
| 380 | } | |
| 381 | ||
| 382 | #[test] | |
| 383 | fn read_only_open_refuses_a_database_that_is_not_there() { | |
| 384 | let missing = std::env::temp_dir().join(format!("signald-absent-{}.db", std::process::id())); | |
| 385 | let _ = std::fs::remove_file(&missing); | |
| 386 | assert!( | |
| 387 | History::open_read_only(&missing).is_err(), | |
| 388 | "read-only must not conjure a database" | |
| 389 | ); | |
| 390 | } | |
| 391 | ||
| 272 | 392 | /// A row written under an older schema cannot be rebuilt from its frame, so |
| 273 | 393 | /// reopening drops it rather than leaving it to be skipped silently. |
| 274 | 394 | #[test] |