use std::path::{Path, PathBuf};
use std::time::Duration;
use kanade_shared::wire::ObsEvent;
use tracing::warn;
const SAMPLE_INTERVAL: Duration = Duration::from_secs(30);
const IDLE_THRESHOLD: Duration = Duration::from_secs(5 * 60);
const SOURCE: &str = "agent:idle_sampler";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Presence {
Active,
Idle,
}
impl Presence {
fn kind(self) -> &'static str {
match self {
Presence::Active => "active",
Presence::Idle => "idle",
}
}
}
fn classify(idle: Option<Duration>, threshold: Duration) -> Option<Presence> {
match idle {
Some(d) if d >= threshold => Some(Presence::Idle),
Some(_) => Some(Presence::Active),
None => None,
}
}
fn step(state: &mut Option<Presence>, reading: Option<Presence>) -> Option<Presence> {
let r = reading?;
if *state == Some(r) {
return None;
}
*state = Some(r);
Some(r)
}
#[cfg(target_os = "windows")]
fn sample_idle() -> Option<Duration> {
crate::env_gate::console_idle()
}
#[cfg(not(target_os = "windows"))]
fn sample_idle() -> Option<Duration> {
None
}
fn build_event(pc_id: &str, p: Presence, at: chrono::DateTime<chrono::Utc>) -> ObsEvent {
ObsEvent {
pc_id: pc_id.to_string(),
at,
kind: p.kind().to_string(),
source: SOURCE.to_string(),
event_record_id: Some(format!("{}:{}", p.kind(), at.timestamp_millis())),
payload: serde_json::Value::Null,
}
}
fn emit(pc_id: &str, dir: &Path, p: Presence) {
let event = build_event(pc_id, p, chrono::Utc::now());
if let Err(e) = crate::obs_outbox::enqueue(dir, &event) {
warn!(error = %e, kind = p.kind(), "idle_sampler: enqueue failed");
}
}
pub async fn run(pc_id: String, obs_outbox_dir: PathBuf) {
if let Err(e) = crate::obs_outbox::ensure_outbox_dir(&obs_outbox_dir) {
warn!(error = %e, "idle_sampler: outbox dir — events may be dropped until it exists");
}
let mut state: Option<Presence> = None;
let mut tick = tokio::time::interval(SAMPLE_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tick.tick().await;
let reading = classify(sample_idle(), IDLE_THRESHOLD);
if let Some(p) = step(&mut state, reading) {
emit(&pc_id, &obs_outbox_dir, p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const THRESH: Duration = Duration::from_secs(300);
#[test]
fn classify_splits_on_threshold() {
assert_eq!(
classify(Some(Duration::ZERO), THRESH),
Some(Presence::Active)
);
assert_eq!(
classify(Some(Duration::from_secs(299)), THRESH),
Some(Presence::Active)
);
assert_eq!(classify(Some(THRESH), THRESH), Some(Presence::Idle));
assert_eq!(
classify(Some(Duration::from_secs(3600)), THRESH),
Some(Presence::Idle)
);
assert_eq!(classify(Some(Duration::MAX), THRESH), Some(Presence::Idle));
assert_eq!(classify(None, THRESH), None);
}
#[test]
fn step_emits_baseline_then_only_on_change() {
let mut state = None;
assert_eq!(
step(&mut state, Some(Presence::Active)),
Some(Presence::Active)
);
assert_eq!(step(&mut state, Some(Presence::Active)), None);
assert_eq!(step(&mut state, Some(Presence::Idle)), Some(Presence::Idle));
assert_eq!(step(&mut state, Some(Presence::Idle)), None);
assert_eq!(
step(&mut state, Some(Presence::Active)),
Some(Presence::Active)
);
}
#[test]
fn step_ignores_unknown_readings() {
let mut state = Some(Presence::Active);
assert_eq!(step(&mut state, None), None);
assert_eq!(state, Some(Presence::Active));
}
#[test]
fn unknown_reading_before_baseline_stays_unset() {
let mut state = None;
assert_eq!(step(&mut state, None), None);
assert_eq!(state, None);
assert_eq!(step(&mut state, Some(Presence::Idle)), Some(Presence::Idle));
}
#[test]
fn build_event_has_stable_dedup_key_and_null_payload() {
let at = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
let e = build_event("PC-01", Presence::Idle, at);
assert_eq!(e.pc_id, "PC-01");
assert_eq!(e.kind, "idle");
assert_eq!(e.source, "agent:idle_sampler");
assert_eq!(e.event_record_id.as_deref(), Some("idle:1700000000000"));
assert_eq!(e.payload, serde_json::Value::Null);
let a = build_event("PC-01", Presence::Active, at);
assert_ne!(a.event_record_id, e.event_record_id);
}
}