hatchery-node 0.2.68

Hatchery node: the complete node kit over the gate4agent transport (PTY/inline sessions, file browser, local git, worktrees, mailbox, browser station, session restore)
Documentation
//! Collect host machine resources + installed provider CLI versions for topology inventory.
//!
//! Snapshots are cached: Snapshot / ListSessions used to call this on every request, and six
//! `sh -c "command -v …"` + `--version` probes took ~700ms and held the bridge request queue,
//! so Resize (view attach) after Spawn never landed before the provisional grace expired.

use hatchery_core::{MachineEnvironment, ProviderCliReport};
use std::fs;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi", "kimi-cli", "bash"];

/// How long a cached environment snapshot is served before a background refresh.
const CACHE_TTL: Duration = Duration::from_secs(60);

struct Cached {
    env: MachineEnvironment,
    at: Instant,
}

fn cache() -> &'static Mutex<Option<Cached>> {
    static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(None))
}

fn refreshing() -> &'static AtomicBool {
    static FLAG: OnceLock<AtomicBool> = OnceLock::new();
    FLAG.get_or_init(|| AtomicBool::new(false))
}

/// Snapshot the host environment. Best-effort: missing fields become 0 / empty.
///
/// Fast path: serve a cached copy. A stale cache kicks off one background refresh so the
/// bridge request worker never blocks on `sh` / `--version`. First call still collects sync.
pub fn collect() -> MachineEnvironment {
    {
        let guard = cache().lock().unwrap_or_else(|p| p.into_inner());
        if let Some(c) = guard.as_ref() {
            let age = c.at.elapsed();
            let env = c.env.clone();
            drop(guard);
            if age >= CACHE_TTL {
                kick_refresh();
            }
            return env;
        }
    }
    let env = collect_fresh();
    *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
        env: env.clone(),
        at: Instant::now(),
    });
    env
}

fn kick_refresh() {
    if refreshing()
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return;
    }
    std::thread::Builder::new()
        .name("machine-env-refresh".into())
        .spawn(|| {
            let env = collect_fresh();
            *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
                env,
                at: Instant::now(),
            });
            refreshing().store(false, Ordering::SeqCst);
        })
        .ok();
}

fn collect_fresh() -> MachineEnvironment {
    let (os, kernel, arch) = uname();
    let hostname = fs::read_to_string("/etc/hostname")
        .or_else(|_| fs::read_to_string("/proc/sys/kernel/hostname"))
        .map(|s| s.trim().to_owned())
        .unwrap_or_else(|_| "unknown".into());
    let uptime_secs = fs::read_to_string("/proc/uptime")
        .ok()
        .and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
        .map(|s| s as u64)
        .unwrap_or(0);
    let cpu_cores = std::thread::available_parallelism()
        .map(|n| n.get() as u32)
        .unwrap_or(1);
    let cpu_model = cpu_model();
    let (ram_total_bytes, ram_available_bytes) = meminfo();
    let (disk_total_bytes, disk_available_bytes) = disk_root();
    let provider_clis = PROVIDERS
        .iter()
        .filter_map(|id| probe_cli(id))
        .collect();
    MachineEnvironment {
        os,
        kernel,
        arch,
        hostname,
        uptime_secs,
        cpu_cores,
        cpu_model,
        ram_total_bytes,
        ram_available_bytes,
        disk_total_bytes,
        disk_available_bytes,
        provider_clis,
    }
}

fn uname() -> (String, String, String) {
    let out = hidden(Command::new("uname")).args(["-srm"]).output().ok();
    let text = out
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .unwrap_or_default();
    let mut parts = text.split_whitespace();
    let os = parts.next().unwrap_or(std::env::consts::OS).to_owned();
    let kernel = parts.next().unwrap_or("unknown").to_owned();
    let arch = parts
        .next()
        .unwrap_or(std::env::consts::ARCH)
        .to_owned();
    (os, kernel, arch)
}

fn cpu_model() -> Option<String> {
    let text = fs::read_to_string("/proc/cpuinfo").ok()?;
    for line in text.lines() {
        if let Some(rest) = line.strip_prefix("model name") {
            return Some(rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()).to_owned());
        }
    }
    None
}

fn meminfo() -> (u64, u64) {
    let text = match fs::read_to_string("/proc/meminfo") {
        Ok(t) => t,
        Err(_) => return (0, 0),
    };
    let mut total_kb = 0u64;
    let mut avail_kb = 0u64;
    for line in text.lines() {
        if let Some(v) = line.strip_prefix("MemTotal:") {
            total_kb = parse_kb(v);
        } else if let Some(v) = line.strip_prefix("MemAvailable:") {
            avail_kb = parse_kb(v);
        }
    }
    (total_kb.saturating_mul(1024), avail_kb.saturating_mul(1024))
}

fn parse_kb(v: &str) -> u64 {
    v.split_whitespace()
        .next()
        .and_then(|n| n.parse().ok())
        .unwrap_or(0)
}

fn disk_root() -> (u64, u64) {
    // Prefer `statvfs` via df -B1 /
    let out = hidden(Command::new("df")).args(["-B1", "/"]).output().ok();
    let text = out.and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
    for line in text.lines().skip(1) {
        let cols: Vec<_> = line.split_whitespace().collect();
        if cols.len() >= 4 {
            let total = cols[1].parse().unwrap_or(0);
            let avail = cols[3].parse().unwrap_or(0);
            return (total, avail);
        }
    }
    (0, 0)
}

fn probe_cli(id: &str) -> Option<ProviderCliReport> {
    let path = hidden(Command::new("sh"))
        .args(["-c", &format!("command -v {id}")])
        .output()
        .ok()
        .and_then(|o| {
            if o.status.success() {
                String::from_utf8(o.stdout).ok().map(|s| s.trim().to_owned())
            } else {
                None
            }
        })
        .filter(|s| !s.is_empty())?;
    let version = hidden(Command::new(&path))
        .arg("--version")
        .output()
        .ok()
        .and_then(|o| {
            let mut text = String::from_utf8_lossy(&o.stdout).to_string();
            if text.trim().is_empty() {
                text = String::from_utf8_lossy(&o.stderr).to_string();
            }
            let line = text.lines().next()?.trim().to_owned();
            if line.is_empty() { None } else { Some(line) }
        });
    Some(ProviderCliReport {
        id: id.to_owned(),
        version,
        path: Some(path),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn collect_returns_nonzero_cores_on_linux() {
        let env = collect();
        assert!(env.cpu_cores >= 1);
        assert!(!env.os.is_empty());
        assert!(!env.arch.is_empty());
    }

    #[test]
    fn second_collect_is_cached_and_fast() {
        let _ = collect();
        let t0 = Instant::now();
        let _ = collect();
        assert!(
            t0.elapsed() < Duration::from_millis(50),
            "cached collect must not re-probe CLIs"
        );
    }
}

/// Every probe child must not pop a console window on Windows.
fn hidden(mut command: Command) -> Command {
    crate::windows_process::hide_console(&mut command);
    command
}