pub mod log;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
pub const RESCUE_ORPHAN_DAYS: u64 = 7;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthFinding {
pub class: HealthClass,
pub severity: Severity,
pub detail: String,
pub auto_repairable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthClass {
Db,
Index,
Vectors,
Editor,
Tree,
Disk,
Backup,
Project,
Rescue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Info,
Warn,
Error,
Critical,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub enum HealthEvent {
Ok,
Warning(HealthFinding),
Repaired(HealthFinding, String),
Error(HealthFinding),
}
impl HealthEvent {
pub fn chip(&self) -> ChipState {
match self {
HealthEvent::Ok => ChipState::Clean,
HealthEvent::Warning(_) => ChipState::Warning,
HealthEvent::Repaired(_, _) => ChipState::Repaired,
HealthEvent::Error(_) => ChipState::Error,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChipState {
#[default]
Hidden,
Clean,
Repaired,
Warning,
Error,
}
impl ChipState {
pub fn glyph(&self) -> &'static str {
match self {
ChipState::Hidden => "",
ChipState::Clean => "✓",
ChipState::Repaired => "✎",
ChipState::Warning => "⚠",
ChipState::Error => "✗",
}
}
}
#[derive(Debug, Clone)]
pub struct MonitorSetup {
pub project_root: PathBuf,
pub backup_dir: PathBuf,
pub backup_max_age: Duration,
pub repair: RepairPolicy,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RepairPolicy {
pub rescue_orphans: bool,
}
pub const RESCUE_REPAIR_DAYS: u64 = 30;
const CADENCE_PROJECT: Duration = Duration::from_secs(90);
const CADENCE_BACKUP: Duration = Duration::from_secs(300);
const CADENCE_RESCUE: Duration = Duration::from_secs(3600);
const LOOP_TICK: Duration = Duration::from_secs(30);
pub fn spawn_monitor(
setup: MonitorSetup,
enabled: bool,
) -> Option<mpsc::UnboundedReceiver<HealthEvent>> {
if !enabled {
return None;
}
let (tx, rx) = mpsc::unbounded_channel::<HealthEvent>();
tokio::spawn(async move {
let mut last = LastRun::default();
let mut interval = tokio::time::interval(LOOP_TICK);
interval.tick().await;
loop {
interval.tick().await;
let mut findings: Vec<HealthEvent> = Vec::new();
let now = Instant::now();
if last.project.map_or(true, |t| now.duration_since(t) >= CADENCE_PROJECT) {
last.project = Some(now);
findings.push(check_project_root(&setup.project_root));
}
if last.backup.map_or(true, |t| now.duration_since(t) >= CADENCE_BACKUP) {
last.backup = Some(now);
findings.push(check_backup_freshness(
&setup.backup_dir,
setup.backup_max_age,
));
}
if last.rescue.map_or(true, |t| now.duration_since(t) >= CADENCE_RESCUE) {
last.rescue = Some(now);
let evt = check_rescue_orphans(&setup.project_root);
let evt = if setup.repair.rescue_orphans
&& matches!(evt, HealthEvent::Warning(_))
{
repair_rescue_orphans(&setup.project_root).unwrap_or(evt)
} else {
evt
};
findings.push(evt);
}
let collapsed = collapse_findings(findings);
log::append(&setup.project_root, &collapsed);
if tx.send(collapsed).is_err() {
break;
}
}
});
Some(rx)
}
fn repair_rescue_orphans(project_root: &Path) -> Option<HealthEvent> {
let threshold = SystemTime::now() - Duration::from_secs(RESCUE_REPAIR_DAYS * 86400);
let mut targets: Vec<(PathBuf, u64)> = Vec::new();
walk_rescues_with_size(project_root, threshold, &mut targets, 0);
if targets.is_empty() {
return None;
}
let mut removed: usize = 0;
let mut bytes: u64 = 0;
for (path, size) in &targets {
if std::fs::remove_file(path).is_ok() {
removed += 1;
bytes += size;
}
}
if removed == 0 {
return None;
}
let note = format!(
"removed {removed} orphan rescue file(s) ({bytes} bytes) older than {} days",
RESCUE_REPAIR_DAYS
);
Some(HealthEvent::Repaired(
HealthFinding {
class: HealthClass::Rescue,
severity: Severity::Info,
detail: note.clone(),
auto_repairable: true,
},
note,
))
}
fn walk_rescues_with_size(
dir: &Path,
threshold: SystemTime,
out: &mut Vec<(PathBuf, u64)>,
depth: usize,
) {
if depth > 12 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "recovered" || name == ".inkhaven" || name == "target" {
continue;
}
walk_rescues_with_size(&path, threshold, out, depth + 1);
} else if ft.is_file() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".inkhaven-rescue") {
continue;
}
let Ok(md) = entry.metadata() else { continue };
let Ok(mtime) = md.modified() else { continue };
if mtime < threshold {
out.push((path, md.len()));
}
}
}
}
#[derive(Default)]
struct LastRun {
project: Option<Instant>,
backup: Option<Instant>,
rescue: Option<Instant>,
}
fn collapse_findings(findings: Vec<HealthEvent>) -> HealthEvent {
let mut best: Option<HealthEvent> = None;
for evt in findings {
let evt_rank = rank(&evt);
let current_rank = best.as_ref().map(rank).unwrap_or(0);
if evt_rank > current_rank {
best = Some(evt);
}
}
best.unwrap_or(HealthEvent::Ok)
}
fn rank(evt: &HealthEvent) -> u8 {
match evt {
HealthEvent::Ok => 1,
HealthEvent::Repaired(_, _) => 2,
HealthEvent::Warning(_) => 3,
HealthEvent::Error(_) => 4,
}
}
pub(crate) fn check_project_root(project_root: &Path) -> HealthEvent {
match std::fs::metadata(project_root) {
Ok(md) if md.is_dir() => HealthEvent::Ok,
Ok(_) => HealthEvent::Warning(HealthFinding {
class: HealthClass::Project,
severity: Severity::Warn,
detail: format!(
"project root {} exists but is not a directory",
project_root.display()
),
auto_repairable: false,
}),
Err(e) => HealthEvent::Error(HealthFinding {
class: HealthClass::Project,
severity: Severity::Critical,
detail: format!(
"project root {} unreachable: {e}",
project_root.display()
),
auto_repairable: false,
}),
}
}
pub(crate) fn check_backup_freshness(
backup_dir: &Path,
max_age: Duration,
) -> HealthEvent {
if max_age.is_zero() {
return HealthEvent::Ok;
}
let newest = newest_zip_mtime(backup_dir);
let Some(mtime) = newest else {
return HealthEvent::Warning(HealthFinding {
class: HealthClass::Backup,
severity: Severity::Warn,
detail: format!(
"no backup found under {} — run `inkhaven backup` or Ctrl+B Shift+B",
backup_dir.display()
),
auto_repairable: false,
});
};
let now = SystemTime::now();
let age = now.duration_since(mtime).unwrap_or(Duration::ZERO);
if age > max_age {
HealthEvent::Warning(HealthFinding {
class: HealthClass::Backup,
severity: Severity::Warn,
detail: format!(
"newest backup is {} old (limit {}); run `inkhaven backup`",
humantime::format_duration(round_to_minutes(age)),
humantime::format_duration(max_age),
),
auto_repairable: false,
})
} else {
HealthEvent::Ok
}
}
fn newest_zip_mtime(dir: &Path) -> Option<SystemTime> {
let entries = std::fs::read_dir(dir).ok()?;
let mut newest: Option<SystemTime> = None;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".zip") {
continue;
}
let Ok(md) = entry.metadata() else { continue };
let Ok(mtime) = md.modified() else { continue };
match newest {
Some(prev) if prev >= mtime => {}
_ => newest = Some(mtime),
}
}
newest
}
fn round_to_minutes(d: Duration) -> Duration {
let secs = d.as_secs();
Duration::from_secs(secs.saturating_sub(secs % 60))
}
pub(crate) fn check_rescue_orphans(project_root: &Path) -> HealthEvent {
let threshold = SystemTime::now() - Duration::from_secs(RESCUE_ORPHAN_DAYS * 86400);
let mut orphans: Vec<PathBuf> = Vec::new();
walk_rescues(project_root, threshold, &mut orphans, 0);
if orphans.is_empty() {
HealthEvent::Ok
} else {
let example = orphans
.first()
.map(|p| p.display().to_string())
.unwrap_or_default();
HealthEvent::Warning(HealthFinding {
class: HealthClass::Rescue,
severity: Severity::Warn,
detail: format!(
"{} rescue file(s) older than {} days under the project — \
e.g. {} — run `inkhaven recover --keep` to inspect or \
delete them manually",
orphans.len(),
RESCUE_ORPHAN_DAYS,
example,
),
auto_repairable: false,
})
}
}
fn walk_rescues(
dir: &Path,
threshold: SystemTime,
out: &mut Vec<PathBuf>,
depth: usize,
) {
if depth > 12 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == "recovered" || name == ".inkhaven" || name == "target" {
continue;
}
walk_rescues(&path, threshold, out, depth + 1);
} else if ft.is_file() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.ends_with(".inkhaven-rescue") {
continue;
}
let Ok(md) = entry.metadata() else { continue };
let Ok(mtime) = md.modified() else { continue };
if mtime < threshold {
out.push(path);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chip_glyphs_distinct() {
let g: std::collections::HashSet<&'static str> = [
ChipState::Clean,
ChipState::Repaired,
ChipState::Warning,
ChipState::Error,
]
.iter()
.map(|s| s.glyph())
.collect();
assert_eq!(g.len(), 4);
}
#[test]
fn check_project_root_ok_for_existing_dir() {
let dir = std::env::temp_dir().join(format!(
"health-h2-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let evt = super::check_project_root(&dir);
assert!(matches!(evt, HealthEvent::Ok));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn check_project_root_error_for_missing() {
let dir = std::env::temp_dir().join(format!(
"health-h2-missing-{}",
std::process::id()
));
let evt = super::check_project_root(&dir);
match evt {
HealthEvent::Error(f) => {
assert_eq!(f.class, HealthClass::Project);
assert_eq!(f.severity, Severity::Critical);
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn spawn_returns_none_when_disabled() {
let rx = super::spawn_monitor(
MonitorSetup {
project_root: PathBuf::from("/tmp/nonexistent"),
backup_dir: PathBuf::from("/tmp/nonexistent-backups"),
backup_max_age: Duration::from_secs(7 * 86400),
repair: RepairPolicy::default(),
},
false,
);
assert!(rx.is_none());
}
#[test]
fn backup_check_disabled_when_max_age_zero() {
let evt = super::check_backup_freshness(
&PathBuf::from("/tmp/whatever-nonexistent"),
Duration::ZERO,
);
assert!(matches!(evt, HealthEvent::Ok));
}
#[test]
fn backup_check_warns_on_missing_dir() {
let evt = super::check_backup_freshness(
&PathBuf::from(format!("/tmp/nx-{}-backups", std::process::id())),
Duration::from_secs(86400),
);
match evt {
HealthEvent::Warning(f) => {
assert_eq!(f.class, HealthClass::Backup);
assert!(f.detail.contains("no backup found"));
}
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn backup_check_ok_for_recent_zip() {
let dir = std::env::temp_dir().join(format!(
"health-backup-test-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let zip = dir.join("blackinkhaven_20260531_120000.zip");
std::fs::write(&zip, b"\x50\x4b\x03\x04 fake zip").unwrap();
let evt = super::check_backup_freshness(&dir, Duration::from_secs(7 * 86400));
assert!(matches!(evt, HealthEvent::Ok), "got {evt:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rescue_check_ok_for_no_orphans() {
let dir = std::env::temp_dir().join(format!(
"health-rescue-empty-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let evt = super::check_rescue_orphans(&dir);
assert!(matches!(evt, HealthEvent::Ok));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rescue_check_warns_on_old_orphan() {
let dir = std::env::temp_dir().join(format!(
"health-rescue-old-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let orphan = dir.join("opening.typ.inkhaven-rescue");
std::fs::write(&orphan, b"old buffer").unwrap();
let mut orphans: Vec<PathBuf> = Vec::new();
let threshold =
SystemTime::now() + Duration::from_secs(60); super::walk_rescues(&dir, threshold, &mut orphans, 0);
assert_eq!(orphans.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collapse_picks_worst() {
let f = HealthFinding {
class: HealthClass::Backup,
severity: Severity::Warn,
detail: "warn".into(),
auto_repairable: false,
};
let result = super::collapse_findings(vec![
HealthEvent::Ok,
HealthEvent::Warning(f.clone()),
HealthEvent::Ok,
]);
assert!(matches!(result, HealthEvent::Warning(_)));
}
#[test]
fn collapse_empty_is_ok() {
assert!(matches!(super::collapse_findings(vec![]), HealthEvent::Ok));
}
#[test]
fn repair_rescue_orphans_skips_recent_files() {
let dir = std::env::temp_dir().join(format!(
"health-repair-recent-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let r = dir.join("opening.typ.inkhaven-rescue");
std::fs::write(&r, b"recent").unwrap();
let result = super::repair_rescue_orphans(&dir);
assert!(result.is_none(), "recent file shouldn't be swept");
assert!(r.exists(), "recent file should still exist after no-op repair");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn repair_rescue_orphans_with_force_threshold_removes_file() {
let dir = std::env::temp_dir().join(format!(
"health-repair-old-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let r = dir.join("ch1/opening.typ.inkhaven-rescue");
std::fs::create_dir_all(r.parent().unwrap()).unwrap();
std::fs::write(&r, b"buffer body").unwrap();
let mut found: Vec<(PathBuf, u64)> = Vec::new();
super::walk_rescues_with_size(
&dir,
SystemTime::now() + Duration::from_secs(60),
&mut found,
0,
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, "buffer body".len() as u64);
let _ = std::fs::remove_dir_all(&dir);
}
}