hatchery_node/
machine_env.rs1use hatchery_core::{MachineEnvironment, ProviderCliReport};
8use std::fs;
9use std::process::Command;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Mutex, OnceLock};
12use std::time::{Duration, Instant};
13
14const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi", "kimi-cli", "bash"];
15
16const CACHE_TTL: Duration = Duration::from_secs(60);
18
19struct Cached {
20 env: MachineEnvironment,
21 at: Instant,
22}
23
24fn cache() -> &'static Mutex<Option<Cached>> {
25 static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
26 CACHE.get_or_init(|| Mutex::new(None))
27}
28
29fn refreshing() -> &'static AtomicBool {
30 static FLAG: OnceLock<AtomicBool> = OnceLock::new();
31 FLAG.get_or_init(|| AtomicBool::new(false))
32}
33
34pub fn collect() -> MachineEnvironment {
39 {
40 let guard = cache().lock().unwrap_or_else(|p| p.into_inner());
41 if let Some(c) = guard.as_ref() {
42 let age = c.at.elapsed();
43 let env = c.env.clone();
44 drop(guard);
45 if age >= CACHE_TTL {
46 kick_refresh();
47 }
48 return env;
49 }
50 }
51 let env = collect_fresh();
52 *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
53 env: env.clone(),
54 at: Instant::now(),
55 });
56 env
57}
58
59fn kick_refresh() {
60 if refreshing()
61 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
62 .is_err()
63 {
64 return;
65 }
66 std::thread::Builder::new()
67 .name("machine-env-refresh".into())
68 .spawn(|| {
69 let env = collect_fresh();
70 *cache().lock().unwrap_or_else(|p| p.into_inner()) = Some(Cached {
71 env,
72 at: Instant::now(),
73 });
74 refreshing().store(false, Ordering::SeqCst);
75 })
76 .ok();
77}
78
79fn collect_fresh() -> MachineEnvironment {
80 let (os, kernel, arch) = uname();
81 let hostname = fs::read_to_string("/etc/hostname")
82 .or_else(|_| fs::read_to_string("/proc/sys/kernel/hostname"))
83 .map(|s| s.trim().to_owned())
84 .unwrap_or_else(|_| "unknown".into());
85 let uptime_secs = fs::read_to_string("/proc/uptime")
86 .ok()
87 .and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
88 .map(|s| s as u64)
89 .unwrap_or(0);
90 let cpu_cores = std::thread::available_parallelism()
91 .map(|n| n.get() as u32)
92 .unwrap_or(1);
93 let cpu_model = cpu_model();
94 let (ram_total_bytes, ram_available_bytes) = meminfo();
95 let (disk_total_bytes, disk_available_bytes) = disk_root();
96 let provider_clis = PROVIDERS
97 .iter()
98 .filter_map(|id| probe_cli(id))
99 .collect();
100 MachineEnvironment {
101 os,
102 kernel,
103 arch,
104 hostname,
105 uptime_secs,
106 cpu_cores,
107 cpu_model,
108 ram_total_bytes,
109 ram_available_bytes,
110 disk_total_bytes,
111 disk_available_bytes,
112 provider_clis,
113 }
114}
115
116fn uname() -> (String, String, String) {
117 let out = hidden(Command::new("uname")).args(["-srm"]).output().ok();
118 let text = out
119 .and_then(|o| String::from_utf8(o.stdout).ok())
120 .unwrap_or_default();
121 let mut parts = text.split_whitespace();
122 let os = parts.next().unwrap_or(std::env::consts::OS).to_owned();
123 let kernel = parts.next().unwrap_or("unknown").to_owned();
124 let arch = parts
125 .next()
126 .unwrap_or(std::env::consts::ARCH)
127 .to_owned();
128 (os, kernel, arch)
129}
130
131fn cpu_model() -> Option<String> {
132 let text = fs::read_to_string("/proc/cpuinfo").ok()?;
133 for line in text.lines() {
134 if let Some(rest) = line.strip_prefix("model name") {
135 return Some(rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()).to_owned());
136 }
137 }
138 None
139}
140
141fn meminfo() -> (u64, u64) {
142 let text = match fs::read_to_string("/proc/meminfo") {
143 Ok(t) => t,
144 Err(_) => return (0, 0),
145 };
146 let mut total_kb = 0u64;
147 let mut avail_kb = 0u64;
148 for line in text.lines() {
149 if let Some(v) = line.strip_prefix("MemTotal:") {
150 total_kb = parse_kb(v);
151 } else if let Some(v) = line.strip_prefix("MemAvailable:") {
152 avail_kb = parse_kb(v);
153 }
154 }
155 (total_kb.saturating_mul(1024), avail_kb.saturating_mul(1024))
156}
157
158fn parse_kb(v: &str) -> u64 {
159 v.split_whitespace()
160 .next()
161 .and_then(|n| n.parse().ok())
162 .unwrap_or(0)
163}
164
165fn disk_root() -> (u64, u64) {
166 let out = hidden(Command::new("df")).args(["-B1", "/"]).output().ok();
168 let text = out.and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
169 for line in text.lines().skip(1) {
170 let cols: Vec<_> = line.split_whitespace().collect();
171 if cols.len() >= 4 {
172 let total = cols[1].parse().unwrap_or(0);
173 let avail = cols[3].parse().unwrap_or(0);
174 return (total, avail);
175 }
176 }
177 (0, 0)
178}
179
180fn probe_cli(id: &str) -> Option<ProviderCliReport> {
181 let path = hidden(Command::new("sh"))
182 .args(["-c", &format!("command -v {id}")])
183 .output()
184 .ok()
185 .and_then(|o| {
186 if o.status.success() {
187 String::from_utf8(o.stdout).ok().map(|s| s.trim().to_owned())
188 } else {
189 None
190 }
191 })
192 .filter(|s| !s.is_empty())?;
193 let version = hidden(Command::new(&path))
194 .arg("--version")
195 .output()
196 .ok()
197 .and_then(|o| {
198 let mut text = String::from_utf8_lossy(&o.stdout).to_string();
199 if text.trim().is_empty() {
200 text = String::from_utf8_lossy(&o.stderr).to_string();
201 }
202 let line = text.lines().next()?.trim().to_owned();
203 if line.is_empty() { None } else { Some(line) }
204 });
205 Some(ProviderCliReport {
206 id: id.to_owned(),
207 version,
208 path: Some(path),
209 })
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn collect_returns_nonzero_cores_on_linux() {
218 let env = collect();
219 assert!(env.cpu_cores >= 1);
220 assert!(!env.os.is_empty());
221 assert!(!env.arch.is_empty());
222 }
223
224 #[test]
225 fn second_collect_is_cached_and_fast() {
226 let _ = collect();
227 let t0 = Instant::now();
228 let _ = collect();
229 assert!(
230 t0.elapsed() < Duration::from_millis(50),
231 "cached collect must not re-probe CLIs"
232 );
233 }
234}
235
236fn hidden(mut command: Command) -> Command {
238 crate::windows_process::hide_console(&mut command);
239 command
240}