oxicode/foundation/
brain_control.rs1use std::path::PathBuf;
18use std::process::Stdio;
19
20pub const BRAIN_SERVICE_LABEL: &str = "com.oxi.oxibrain";
22
23pub fn brain_plist_path() -> Option<PathBuf> {
25 let home = std::env::var_os("HOME")?;
26 let mut p = PathBuf::from(home);
27 p.push("Library");
28 p.push("LaunchAgents");
29 p.push(format!("{BRAIN_SERVICE_LABEL}.plist"));
30 Some(p)
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ReviveAction {
36 BootstrapPlist(PathBuf),
38 KickstartService,
40 SpawnDetached(PathBuf),
42 InstallNeeded,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct BrainControlReport {
49 pub binary: Option<PathBuf>,
51 pub plist: Option<PathBuf>,
53 pub service_loaded: bool,
55}
56
57pub fn revive_plan(report: &BrainControlReport) -> ReviveAction {
59 let Some(binary) = report.binary.clone() else {
60 return ReviveAction::InstallNeeded;
61 };
62 match &report.plist {
63 Some(plist) if !report.service_loaded => ReviveAction::BootstrapPlist(plist.clone()),
64 Some(_) => ReviveAction::KickstartService,
65 None => ReviveAction::SpawnDetached(binary),
66 }
67}
68
69pub fn find_oxibrain_binary() -> Option<PathBuf> {
78 use std::path::PathBuf;
79 let home = std::env::var_os("HOME").map(PathBuf::from);
80 let mut candidates: Vec<PathBuf> = Vec::new();
81 if let Some(h) = &home {
82 let mut managed = h.clone();
83 managed.push(".oxi");
84 managed.push("oxibrain");
85 managed.push("bin");
86 managed.push("oxibrain");
87 candidates.push(managed);
88 let mut p = h.clone();
89 p.push(".cargo");
90 p.push("bin");
91 p.push("oxibrain");
92 candidates.push(p);
93 }
94 if let Some(path) = std::env::var_os("PATH") {
95 for dir in std::env::split_paths(&path) {
96 candidates.push(dir.join("oxibrain"));
97 }
98 }
99 let hits: Vec<PathBuf> = candidates
100 .into_iter()
101 .filter(|c| c.is_file())
102 .collect::<std::collections::BTreeSet<_>>()
103 .into_iter()
104 .collect();
105 let winner = hits.first().cloned()?;
106 Some(winner)
107}
108
109fn warn_shadowed_roots(winner: &std::path::Path, hits: &[std::path::PathBuf]) {
112 use std::sync::Once;
113 static WARNED: Once = Once::new();
114 let others: Vec<&std::path::Path> = hits
115 .iter()
116 .skip(1)
117 .map(|p| p.as_path())
118 .filter(|p| *p != winner)
119 .collect();
120 if others.is_empty() {
121 return;
122 }
123 WARNED.call_once(|| {
124 let names = others
125 .iter()
126 .map(|p| p.display().to_string())
127 .collect::<Vec<_>>()
128 .join(", ");
129 tracing::warn!(
130 winner = %winner.display(),
131 shadows = %names,
132 "oxibrain resolved at the managed launcher; shadowed copies exist — consider `cargo uninstall oxibrain-cli` or `rm <path>` to converge"
133 );
134 });
135}
136
137pub fn service_loaded() -> bool {
139 if !cfg!(target_os = "macos") {
140 return false;
141 }
142 let uid = std::process::id();
143 let _ = uid;
144 let user = whoami_uid();
145 let out = std::process::Command::new("launchctl")
146 .args(["print", &format!("gui/{user}/{BRAIN_SERVICE_LABEL}")])
147 .stdout(Stdio::null())
148 .stderr(Stdio::null())
149 .status();
150 matches!(out, Ok(st) if st.success())
151}
152
153pub fn should_auto_revive(
159 memory_enabled: bool,
160 daemon_down: bool,
161 already_attempted: bool,
162) -> bool {
163 memory_enabled && daemon_down && !already_attempted
164}
165fn whoami_uid() -> String {
166 std::process::Command::new("id")
167 .arg("-u")
168 .output()
169 .ok()
170 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
171 .unwrap_or_else(|| "501".to_string())
172}
173
174pub fn probe_control() -> BrainControlReport {
176 BrainControlReport {
177 binary: find_oxibrain_binary(),
178 plist: brain_plist_path().filter(|p| p.is_file()),
179 service_loaded: service_loaded(),
180 }
181}
182
183pub async fn revive() -> Result<String, String> {
186 let report = probe_control();
187 let plan = revive_plan(&report);
188 match plan {
189 ReviveAction::InstallNeeded => Err(
190 "oxibrain binary not found — install it first (`oxios brain install`, \
191 or `cargo install oxibrain-cli`; managed location \
192 ~/.oxi/oxibrain/bin/oxibrain), then run /brain again"
193 .to_string(),
194 ),
195 ReviveAction::BootstrapPlist(plist) => {
196 let user = whoami_uid();
197 let domain = format!("gui/{user}");
198 let status = std::process::Command::new("launchctl")
199 .args(["bootstrap", &domain])
200 .arg(&plist)
201 .status()
202 .map_err(|e| format!("launchctl bootstrap failed: {e}"))?;
203 if !status.success() {
204 return Err(format!(
205 "launchctl bootstrap exited {} — check `launchctl print {domain}/{BRAIN_SERVICE_LABEL}`",
206 status.code().unwrap_or(-1)
207 ));
208 }
209 wait_for_ping("bootstrapped the launchd service").await
210 }
211 ReviveAction::KickstartService => {
212 let user = whoami_uid();
213 let target = format!("gui/{user}/{BRAIN_SERVICE_LABEL}");
214 let status = std::process::Command::new("launchctl")
215 .args(["kickstart", &target])
216 .status()
217 .map_err(|e| format!("launchctl kickstart failed: {e}"))?;
218 if !status.success() {
219 return Err(format!(
220 "launchctl kickstart exited {} — check `launchctl print {target}`",
221 status.code().unwrap_or(-1)
222 ));
223 }
224 wait_for_ping("kicked the launchd service").await
225 }
226 ReviveAction::SpawnDetached(binary) => {
227 let socket = crate::foundation::brain::default_socket_path();
228 let mut cmd = std::process::Command::new(&binary);
229 cmd.args(["serve", "--daemon"])
230 .arg("--socket")
231 .arg(&socket)
232 .stdin(Stdio::null())
233 .stdout(Stdio::null())
234 .stderr(Stdio::null());
235 #[cfg(unix)]
236 {
237 use std::os::unix::process::CommandExt;
238 cmd.process_group(0);
239 }
240 cmd.spawn()
241 .map_err(|e| format!("spawning oxibrain failed: {e}"))?;
242 wait_for_ping("spawned a detached daemon").await
243 }
244 }
245}
246
247async fn wait_for_ping(action: &str) -> Result<String, String> {
249 let backend = crate::foundation::brain::BrainMemoryBackend::new(
250 crate::foundation::brain::default_socket_path(),
251 );
252 for _ in 0..20 {
253 if backend.ping().await.is_ok() {
254 return Ok(format!("{action} — daemon is answering pings"));
255 }
256 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
257 }
258 Err(format!(
259 "{action}, but the daemon did not answer within 5s — check the log at \
260 ~/.oxi/brain/daemon.log"
261 ))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn bin() -> PathBuf {
269 PathBuf::from("/usr/local/bin/oxibrain")
270 }
271
272 #[test]
273 fn plan_without_binary_requires_install() {
274 let report = BrainControlReport {
275 binary: None,
276 plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
277 service_loaded: false,
278 };
279 assert_eq!(revive_plan(&report), ReviveAction::InstallNeeded);
280 }
281
282 #[test]
283 fn plan_bootstraps_an_unloaded_service() {
284 let report = BrainControlReport {
285 binary: Some(bin()),
286 plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
287 service_loaded: false,
288 };
289 assert_eq!(
290 revive_plan(&report),
291 ReviveAction::BootstrapPlist(PathBuf::from("/Library/LaunchAgents/x.plist"))
292 );
293 }
294
295 #[test]
296 fn plan_kickstarts_a_loaded_service() {
297 let report = BrainControlReport {
298 binary: Some(bin()),
299 plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
300 service_loaded: true,
301 };
302 assert_eq!(revive_plan(&report), ReviveAction::KickstartService);
303 }
304
305 #[test]
306 fn plan_spawns_detached_without_launchd() {
307 let report = BrainControlReport {
308 binary: Some(bin()),
309 plist: None,
310 service_loaded: false,
311 };
312 assert_eq!(revive_plan(&report), ReviveAction::SpawnDetached(bin()));
313 }
314
315 #[test]
316 fn auto_revive_gates() {
317 assert!(should_auto_revive(true, true, false), "enabled+down → go");
318 assert!(
319 !should_auto_revive(false, true, false),
320 "memory disabled → never"
321 );
322 assert!(
323 !should_auto_revive(true, true, true),
324 "one attempt per session — no retry loops"
325 );
326 assert!(
327 !should_auto_revive(true, false, false),
328 "healthy daemon → nothing to do"
329 );
330 }
331}