Skip to main content

oxicode/foundation/
brain_control.rs

1//! oxibrain daemon lifecycle control — bring a `brain·down` daemon back.
2//!
3//! The TUI's health chip only *observes* (`brain.rs` probes); this module
4//! *acts*. When the daemon is installed but stopped, `/brain` revives it:
5//!
6//! 1. **launchd** (macOS, plist present): `launchctl bootstrap` a service
7//!    that is not loaded, `launchctl kickstart` one that is. launchd keeps
8//!    supervising it afterwards (KeepAlive), which is the correct home for
9//!    a daemon — `oxibrain serve --daemon` deliberately does not fork.
10//! 2. **Detached spawn** (no plist / non-macOS): start
11//!    `oxibrain serve --daemon --socket <canonical>` in its own process
12//!    group with null stdio. The orphan survives oxicode's exit.
13//!
14//! When the binary is missing entirely, revival is impossible — the
15//! caller surfaces install guidance instead of pretending.
16
17use std::path::PathBuf;
18use std::process::Stdio;
19
20/// launchd service label used by the oxibrain plist.
21pub const BRAIN_SERVICE_LABEL: &str = "com.oxi.oxibrain";
22
23/// Canonical plist location (`~/Library/LaunchAgents/com.oxi.oxibrain.plist`).
24pub 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/// What the revive step decided to do.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ReviveAction {
36    /// Service exists but is not loaded — bootstrap the plist into launchd.
37    BootstrapPlist(PathBuf),
38    /// Service is loaded but stopped — kickstart it.
39    KickstartService,
40    /// No launchd supervision available — spawn detached.
41    SpawnDetached(PathBuf),
42    /// No oxibrain binary — cannot revive.
43    InstallNeeded,
44}
45
46/// Environment facts the plan is derived from (injectable for tests).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct BrainControlReport {
49    /// Resolved `oxibrain` binary, if any.
50    pub binary: Option<PathBuf>,
51    /// Existing launchd plist path, if any.
52    pub plist: Option<PathBuf>,
53    /// Whether the service is currently loaded into launchd.
54    pub service_loaded: bool,
55}
56
57/// Decide how to revive from a control report. Pure.
58pub 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
69/// Locate the `oxibrain` binary: the ecosystem-standard managed install
70/// (`~/.oxi/oxibrain/bin/oxibrain`) first, then `~/.cargo/bin`
71/// (cargo-installed), then `PATH`.
72///
73/// If the binary resolves at the managed location *and* shows up in a
74/// cargo bin or PATH entry as well, [`warn_shadowed_roots`] logs the
75/// shadowed path names once per process so the operator can clean them
76/// up (`cargo uninstall oxibrain-cli`, or `rm <path>`).
77pub 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
109/// Log a once-per-process warning naming the additional `oxibrain`
110/// binaries that resolved in recognized roots after the first hit.
111fn 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
137/// Whether the launchd service is loaded (macOS). Non-macOS: false.
138pub 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
153/// Whether the background prober should attempt an automatic revive.
154/// One attempt per session (success or failure — a broken daemon must
155/// not spawn a retry loop), only for users who enabled memory, and
156/// never when the binary is missing (that is an install decision, not a
157/// restart). Pure.
158pub 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
174/// Probe the environment for a control report.
175pub 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
183/// Run the plan and wait for the daemon to answer a ping.
184/// Returns human-readable outcome lines on success.
185pub 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
247/// Poll the daemon until it answers or the budget runs out.
248async 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}