use std::time::Duration;
use async_nats::connection::State;
use kanade_shared::ipc::state::{Check, CheckStatus, StateSnapshot};
use kanade_shared::wire::EffectiveConfig;
use tokio::sync::watch;
use tracing::debug;
const EVAL_INTERVAL: Duration = Duration::from_secs(30);
pub fn client_online(client: &async_nats::Client) -> bool {
client.connection_state() == State::Connected
}
pub fn eval_once(
pc_id: &str,
agent_version: &str,
cfg: &EffectiveConfig,
online: bool,
extra_checks: &[Check],
) -> StateSnapshot {
let intrinsic = vec![
agent_self_update_check(agent_version, cfg.target_version.as_deref()),
disk_free_check(),
];
let checks = crate::check_cache::merge_checks(intrinsic, extra_checks);
StateSnapshot {
pc_id: pc_id.to_string(),
online,
vpn: "unknown".to_string(),
checks,
agent_version: agent_version.to_string(),
target_version: cfg
.target_version
.as_deref()
.filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| agent_version.to_string()),
}
}
pub async fn eval_loop(
state_tx: watch::Sender<StateSnapshot>,
cfg_rx: watch::Receiver<EffectiveConfig>,
pc_id: String,
agent_version: String,
client: async_nats::Client,
check_sink: crate::check_cache::CheckSink,
) {
let mut tick = tokio::time::interval(EVAL_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tick.tick().await;
loop {
tokio::select! {
_ = tick.tick() => {}
_ = check_sink.wait() => {}
}
let snapshot = eval_once(
&pc_id,
&agent_version,
&cfg_rx.borrow(),
client_online(&client),
&check_sink.checks(),
);
if state_tx.send(snapshot).is_err() {
debug!(pc_id = %pc_id, "state.eval_loop: no receivers, exiting");
return;
}
}
}
fn agent_self_update_check(running: &str, target: Option<&str>) -> Check {
let target = target.filter(|s| !s.is_empty()).unwrap_or(running);
if running == target {
Check {
name: "agent_self_update".into(),
status: CheckStatus::Ok,
detail: Some(format!("running {running} (target matches)")),
troubleshoot: None,
}
} else {
Check {
name: "agent_self_update".into(),
status: CheckStatus::Warn,
detail: Some(format!(
"running {running}, target {target} — restart pending"
)),
troubleshoot: None,
}
}
}
#[cfg(target_os = "windows")]
fn disk_free_check() -> Check {
use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
use windows::core::w;
let mut free: u64 = 0;
let mut total: u64 = 0;
let result =
unsafe { GetDiskFreeSpaceExW(w!("C:\\"), None, Some(&mut total), Some(&mut free)) };
if let Err(e) = result {
return Check {
name: "disk_free".into(),
status: CheckStatus::Unknown,
detail: Some(format!("GetDiskFreeSpaceExW failed: {e}")),
troubleshoot: None,
};
}
if total == 0 {
return Check {
name: "disk_free".into(),
status: CheckStatus::Unknown,
detail: Some("C:\\ reports 0 total bytes".into()),
troubleshoot: None,
};
}
let pct = (free as f64 / total as f64) * 100.0;
let to_gb = |b: u64| (b as f64) / 1024.0 / 1024.0 / 1024.0;
let detail = Some(format!(
"{:.1}% free ({:.1} GB / {:.1} GB)",
pct,
to_gb(free),
to_gb(total),
));
let status = if pct >= 10.0 {
CheckStatus::Ok
} else if pct >= 5.0 {
CheckStatus::Warn
} else {
CheckStatus::Fail
};
Check {
name: "disk_free".into(),
status,
detail,
troubleshoot: None,
}
}
#[cfg(not(target_os = "windows"))]
fn disk_free_check() -> Check {
Check {
name: "disk_free".into(),
status: CheckStatus::Unknown,
detail: Some("disk_free not implemented on non-Windows targets".into()),
troubleshoot: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(target: Option<&str>) -> EffectiveConfig {
let mut cfg = EffectiveConfig::builtin_defaults();
cfg.target_version = target.map(str::to_owned);
cfg
}
#[test]
fn eval_once_produces_well_formed_snapshot() {
let snap = eval_once("PC1234", "0.41.0", &cfg_with(None), true, &[]);
assert_eq!(snap.pc_id, "PC1234");
assert!(snap.online);
assert_eq!(snap.vpn, "unknown");
assert_eq!(snap.agent_version, "0.41.0");
assert_eq!(snap.target_version, "0.41.0"); let names: Vec<&str> = snap.checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["agent_self_update", "disk_free"]);
}
#[test]
fn eval_once_merges_operator_checks() {
let extra = vec![
Check {
name: "bitlocker".into(),
status: CheckStatus::Warn,
detail: Some("D: unprotected".into()),
troubleshoot: Some("fix-bitlocker".into()),
},
Check {
name: "disk_free".into(),
status: CheckStatus::Fail,
detail: Some("operator override".into()),
troubleshoot: None,
},
];
let snap = eval_once("PC1234", "0.41.0", &cfg_with(None), true, &extra);
let names: Vec<&str> = snap.checks.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"agent_self_update"));
assert!(names.contains(&"bitlocker"));
assert_eq!(names.iter().filter(|n| **n == "disk_free").count(), 1);
let disk = snap.checks.iter().find(|c| c.name == "disk_free").unwrap();
assert_eq!(disk.status, CheckStatus::Fail);
}
#[test]
fn eval_once_online_reflects_the_passed_flag() {
let offline = eval_once("PC1234", "0.41.0", &cfg_with(None), false, &[]);
assert!(!offline.online, "online must follow the passed flag");
let online = eval_once("PC1234", "0.41.0", &cfg_with(None), true, &[]);
assert!(online.online);
}
#[test]
fn agent_self_update_ok_when_running_matches_target() {
let c = agent_self_update_check("0.41.0", Some("0.41.0"));
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn agent_self_update_ok_when_target_unset() {
let c = agent_self_update_check("0.41.0", None);
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn agent_self_update_ok_when_target_empty_string() {
let c = agent_self_update_check("0.41.0", Some(""));
assert_eq!(c.status, CheckStatus::Ok);
}
#[test]
fn agent_self_update_warn_when_target_differs() {
let c = agent_self_update_check("0.41.0", Some("0.42.0"));
assert_eq!(c.status, CheckStatus::Warn);
assert!(c.detail.unwrap().contains("restart pending"));
}
#[cfg(target_os = "windows")]
#[test]
fn disk_free_returns_concrete_status_on_windows() {
let c = disk_free_check();
assert_eq!(c.name, "disk_free");
assert!(
matches!(
c.status,
CheckStatus::Ok | CheckStatus::Warn | CheckStatus::Fail
),
"expected concrete status, got {:?}",
c.status
);
let detail = c.detail.expect("detail populated");
assert!(detail.contains("free"), "detail: {detail}");
}
#[test]
fn snapshot_target_version_falls_back_when_cfg_target_empty() {
let snap = eval_once("PC1234", "0.41.0", &cfg_with(Some("")), true, &[]);
assert_eq!(snap.target_version, "0.41.0");
}
#[test]
fn snapshot_target_version_surfaces_when_cfg_target_set() {
let snap = eval_once("PC1234", "0.41.0", &cfg_with(Some("0.42.0")), true, &[]);
assert_eq!(snap.target_version, "0.42.0");
}
}