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: `~/.cargo/bin` first (cargo-installed),
70/// then `PATH`.
71pub fn find_oxibrain_binary() -> Option<PathBuf> {
72    if let Some(home) = std::env::var_os("HOME") {
73        let mut p = PathBuf::from(home);
74        p.push(".cargo");
75        p.push("bin");
76        p.push("oxibrain");
77        if p.is_file() {
78            return Some(p);
79        }
80    }
81    let path = std::env::var_os("PATH")?;
82    for dir in std::env::split_paths(&path) {
83        let candidate = dir.join("oxibrain");
84        if candidate.is_file() {
85            return Some(candidate);
86        }
87    }
88    None
89}
90
91/// Whether the launchd service is loaded (macOS). Non-macOS: false.
92pub fn service_loaded() -> bool {
93    if !cfg!(target_os = "macos") {
94        return false;
95    }
96    let uid = std::process::id();
97    let _ = uid;
98    let user = whoami_uid();
99    let out = std::process::Command::new("launchctl")
100        .args(["print", &format!("gui/{user}/{BRAIN_SERVICE_LABEL}")])
101        .stdout(Stdio::null())
102        .stderr(Stdio::null())
103        .status();
104    matches!(out, Ok(st) if st.success())
105}
106
107/// Whether the background prober should attempt an automatic revive.
108/// One attempt per session (success or failure — a broken daemon must
109/// not spawn a retry loop), only for users who enabled memory, and
110/// never when the binary is missing (that is an install decision, not a
111/// restart). Pure.
112pub fn should_auto_revive(
113    memory_enabled: bool,
114    daemon_down: bool,
115    already_attempted: bool,
116) -> bool {
117    memory_enabled && daemon_down && !already_attempted
118}
119fn whoami_uid() -> String {
120    std::process::Command::new("id")
121        .arg("-u")
122        .output()
123        .ok()
124        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
125        .unwrap_or_else(|| "501".to_string())
126}
127
128/// Probe the environment for a control report.
129pub fn probe_control() -> BrainControlReport {
130    BrainControlReport {
131        binary: find_oxibrain_binary(),
132        plist: brain_plist_path().filter(|p| p.is_file()),
133        service_loaded: service_loaded(),
134    }
135}
136
137/// Run the plan and wait for the daemon to answer a ping.
138/// Returns human-readable outcome lines on success.
139pub async fn revive() -> Result<String, String> {
140    let report = probe_control();
141    let plan = revive_plan(&report);
142    match plan {
143        ReviveAction::InstallNeeded => Err(
144            "oxibrain binary not found — install it first (e.g. `cargo install oxibrain` \
145             from the oxibrain repo), then run /brain again"
146                .to_string(),
147        ),
148        ReviveAction::BootstrapPlist(plist) => {
149            let user = whoami_uid();
150            let domain = format!("gui/{user}");
151            let status = std::process::Command::new("launchctl")
152                .args(["bootstrap", &domain])
153                .arg(&plist)
154                .status()
155                .map_err(|e| format!("launchctl bootstrap failed: {e}"))?;
156            if !status.success() {
157                return Err(format!(
158                    "launchctl bootstrap exited {} — check `launchctl print {domain}/{BRAIN_SERVICE_LABEL}`",
159                    status.code().unwrap_or(-1)
160                ));
161            }
162            wait_for_ping("bootstrapped the launchd service").await
163        }
164        ReviveAction::KickstartService => {
165            let user = whoami_uid();
166            let target = format!("gui/{user}/{BRAIN_SERVICE_LABEL}");
167            let status = std::process::Command::new("launchctl")
168                .args(["kickstart", &target])
169                .status()
170                .map_err(|e| format!("launchctl kickstart failed: {e}"))?;
171            if !status.success() {
172                return Err(format!(
173                    "launchctl kickstart exited {} — check `launchctl print {target}`",
174                    status.code().unwrap_or(-1)
175                ));
176            }
177            wait_for_ping("kicked the launchd service").await
178        }
179        ReviveAction::SpawnDetached(binary) => {
180            let socket = crate::foundation::brain::default_socket_path();
181            let mut cmd = std::process::Command::new(&binary);
182            cmd.args(["serve", "--daemon"])
183                .arg("--socket")
184                .arg(&socket)
185                .stdin(Stdio::null())
186                .stdout(Stdio::null())
187                .stderr(Stdio::null());
188            #[cfg(unix)]
189            {
190                use std::os::unix::process::CommandExt;
191                cmd.process_group(0);
192            }
193            cmd.spawn()
194                .map_err(|e| format!("spawning oxibrain failed: {e}"))?;
195            wait_for_ping("spawned a detached daemon").await
196        }
197    }
198}
199
200/// Poll the daemon until it answers or the budget runs out.
201async fn wait_for_ping(action: &str) -> Result<String, String> {
202    let backend = crate::foundation::brain::BrainMemoryBackend::new(
203        crate::foundation::brain::default_socket_path(),
204    );
205    for _ in 0..20 {
206        if backend.ping().await.is_ok() {
207            return Ok(format!("{action} — daemon is answering pings"));
208        }
209        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
210    }
211    Err(format!(
212        "{action}, but the daemon did not answer within 5s — check the log at \
213         ~/.oxi/brain/daemon.log"
214    ))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn bin() -> PathBuf {
222        PathBuf::from("/usr/local/bin/oxibrain")
223    }
224
225    #[test]
226    fn plan_without_binary_requires_install() {
227        let report = BrainControlReport {
228            binary: None,
229            plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
230            service_loaded: false,
231        };
232        assert_eq!(revive_plan(&report), ReviveAction::InstallNeeded);
233    }
234
235    #[test]
236    fn plan_bootstraps_an_unloaded_service() {
237        let report = BrainControlReport {
238            binary: Some(bin()),
239            plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
240            service_loaded: false,
241        };
242        assert_eq!(
243            revive_plan(&report),
244            ReviveAction::BootstrapPlist(PathBuf::from("/Library/LaunchAgents/x.plist"))
245        );
246    }
247
248    #[test]
249    fn plan_kickstarts_a_loaded_service() {
250        let report = BrainControlReport {
251            binary: Some(bin()),
252            plist: Some(PathBuf::from("/Library/LaunchAgents/x.plist")),
253            service_loaded: true,
254        };
255        assert_eq!(revive_plan(&report), ReviveAction::KickstartService);
256    }
257
258    #[test]
259    fn plan_spawns_detached_without_launchd() {
260        let report = BrainControlReport {
261            binary: Some(bin()),
262            plist: None,
263            service_loaded: false,
264        };
265        assert_eq!(revive_plan(&report), ReviveAction::SpawnDetached(bin()));
266    }
267
268    #[test]
269    fn auto_revive_gates() {
270        assert!(should_auto_revive(true, true, false), "enabled+down → go");
271        assert!(
272            !should_auto_revive(false, true, false),
273            "memory disabled → never"
274        );
275        assert!(
276            !should_auto_revive(true, true, true),
277            "one attempt per session — no retry loops"
278        );
279        assert!(
280            !should_auto_revive(true, false, false),
281            "healthy daemon → nothing to do"
282        );
283    }
284}