Skip to main content

omni_dev/cli/
daemon.rs

1//! `omni-dev daemon` — supervise the long-lived daemon and its services.
2
3pub(crate) mod bridge;
4pub(crate) mod control;
5pub(crate) mod logs;
6pub(crate) mod restart;
7pub(crate) mod run;
8pub(crate) mod service;
9pub(crate) mod start;
10pub(crate) mod status;
11pub(crate) mod stop;
12
13use anyhow::Result;
14use clap::{Parser, Subcommand};
15
16use crate::daemon::protocol::StatusReport;
17
18/// Daemon: host long-lived services (e.g. the browser bridge) under one
19/// supervised, menu-bar-controllable process.
20#[derive(Parser)]
21pub struct DaemonCommand {
22    /// The daemon subcommand to execute.
23    #[command(subcommand)]
24    pub command: DaemonSubcommands,
25}
26
27/// Daemon subcommands.
28#[derive(Subcommand)]
29pub enum DaemonSubcommands {
30    /// Runs the daemon in the foreground (the process launchd execs).
31    Run(run::RunCommand),
32    /// Starts the daemon in the background.
33    Start(start::StartCommand),
34    /// Stops the running daemon.
35    Stop(stop::StopCommand),
36    /// Restarts the daemon.
37    Restart(restart::RestartCommand),
38    /// Reports daemon and per-service status.
39    Status(status::StatusCommand),
40    /// Reads (and optionally follows) the daemon's log file.
41    Logs(logs::LogsCommand),
42    /// Controls the daemon-hosted browser bridge (restart, disconnect a tab, …).
43    Bridge(bridge::BridgeCommand),
44    /// Sends an arbitrary operation to any daemon service (low-level escape hatch).
45    Service(service::ServiceCommand),
46}
47
48/// Prints a non-fatal warning when the resident daemon differs from this CLI
49/// binary — the client is driving a stale daemon after a binary upgrade (#1113).
50///
51/// Comparison prefers the git commit SHA when both sides know theirs, so a
52/// rebuilt-but-same-crate-version daemon the CLI has outrun is still flagged
53/// (#1374); it falls back to crate-version string equality otherwise. A daemon
54/// that advertises neither is treated as unknown and never warns.
55pub(crate) fn warn_version_mismatch(report: &StatusReport) {
56    if is_daemon_stale(
57        report.version.as_deref(),
58        report.provenance.commit_long.as_deref(),
59        crate::VERSION,
60        crate::build_info::GIT_SHA,
61    ) {
62        let cli = describe_build(crate::VERSION, crate::build_info::GIT_SHA_SHORT);
63        let daemon = describe_build(
64            report.version.as_deref().unwrap_or("unknown"),
65            report.provenance.commit.as_deref(),
66        );
67        eprintln!(
68            "warning: omni-dev CLI {cli} is talking to daemon {daemon}; \
69             run `omni-dev daemon restart` to upgrade the resident daemon"
70        );
71    }
72}
73
74/// Whether a resident daemon is stale relative to this CLI. When both sides
75/// report a commit SHA the comparison is commit-level (catching a same-version
76/// rebuild); otherwise it falls back to crate-version string equality. A daemon
77/// that advertises no version and no commit is never stale.
78fn is_daemon_stale(
79    daemon_version: Option<&str>,
80    daemon_commit: Option<&str>,
81    cli_version: &str,
82    cli_commit: Option<&str>,
83) -> bool {
84    match (cli_commit, daemon_commit) {
85        // Both know their commit: a mismatch means different code even at the
86        // same crate version (#1374).
87        (Some(cli), Some(daemon)) => cli != daemon,
88        // Otherwise fall back to crate-version equality (a pre-#1374 daemon, or
89        // a build with no git metadata on either side).
90        _ => matches!(daemon_version, Some(v) if v != cli_version),
91    }
92}
93
94/// Formats a `v<version> (<short-sha>)` build descriptor, omitting the commit
95/// when unknown.
96fn describe_build(version: &str, short_commit: Option<&str>) -> String {
97    match short_commit {
98        Some(commit) => format!("v{version} ({commit})"),
99        None => format!("v{version}"),
100    }
101}
102
103/// Sends one operation to a named daemon service over the control socket and
104/// returns its payload, turning an `ok: false` reply into an error. Stamps the
105/// caller's request-log `invocation_id` so any HTTP the daemon issues while
106/// serving the op correlates back to this invocation (#1198). Shared by the
107/// typed `daemon bridge` command and the generic `daemon service` passthrough.
108pub(crate) async fn call_service(
109    socket: &std::path::Path,
110    service: &str,
111    op: &str,
112    payload: serde_json::Value,
113) -> Result<serde_json::Value> {
114    use crate::daemon::client::DaemonClient;
115    use crate::daemon::protocol::DaemonEnvelope;
116
117    let origin = crate::request_log::current_context().invocation_id;
118    let reply = DaemonClient::new(socket)
119        .request(DaemonEnvelope::service(service, op, payload).with_origin(origin))
120        .await?;
121    if reply.ok {
122        Ok(reply.payload)
123    } else {
124        anyhow::bail!(
125            "daemon returned an error: {}",
126            reply.error.as_deref().unwrap_or("unknown error")
127        )
128    }
129}
130
131impl DaemonCommand {
132    /// Executes the daemon command.
133    pub async fn execute(self) -> Result<()> {
134        match self.command {
135            DaemonSubcommands::Run(cmd) => cmd.execute().await,
136            DaemonSubcommands::Start(cmd) => cmd.execute().await,
137            DaemonSubcommands::Stop(cmd) => cmd.execute().await,
138            DaemonSubcommands::Restart(cmd) => cmd.execute().await,
139            DaemonSubcommands::Status(cmd) => cmd.execute().await,
140            DaemonSubcommands::Logs(cmd) => cmd.execute().await,
141            DaemonSubcommands::Bridge(cmd) => cmd.execute().await,
142            DaemonSubcommands::Service(cmd) => cmd.execute().await,
143        }
144    }
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used, clippy::expect_used)]
149mod tests {
150    use super::*;
151    use std::path::Path;
152
153    /// Mirrors the `omni-dev daemon` argv surface for parse tests.
154    #[derive(Parser)]
155    struct Wrapper {
156        #[command(subcommand)]
157        cmd: DaemonSubcommands,
158    }
159
160    fn parse(args: &[&str]) -> DaemonSubcommands {
161        let mut full = vec!["omni-dev"];
162        full.extend_from_slice(args);
163        Wrapper::try_parse_from(full).unwrap().cmd
164    }
165
166    #[test]
167    fn parses_all_subcommands() {
168        assert!(matches!(parse(&["run"]), DaemonSubcommands::Run(_)));
169        assert!(matches!(parse(&["start"]), DaemonSubcommands::Start(_)));
170        assert!(matches!(parse(&["stop"]), DaemonSubcommands::Stop(_)));
171        assert!(matches!(parse(&["restart"]), DaemonSubcommands::Restart(_)));
172        assert!(matches!(parse(&["status"]), DaemonSubcommands::Status(_)));
173        assert!(matches!(parse(&["logs"]), DaemonSubcommands::Logs(_)));
174        assert!(matches!(
175            parse(&["bridge", "status"]),
176            DaemonSubcommands::Bridge(_)
177        ));
178        assert!(matches!(
179            parse(&["service", "browser-bridge", "status"]),
180            DaemonSubcommands::Service(_)
181        ));
182    }
183
184    #[test]
185    fn logs_flags_and_defaults_parse() {
186        let DaemonSubcommands::Logs(cmd) = parse(&["logs"]) else {
187            panic!("expected logs");
188        };
189        assert_eq!(cmd.lines, 200);
190        assert!(!cmd.follow);
191        assert!(cmd.socket.is_none());
192
193        let DaemonSubcommands::Logs(cmd) =
194            parse(&["logs", "-f", "-n", "50", "--socket", "/tmp/d.sock"])
195        else {
196            panic!("expected logs");
197        };
198        assert!(cmd.follow);
199        assert_eq!(cmd.lines, 50);
200        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
201    }
202
203    #[test]
204    fn is_daemon_stale_falls_back_to_version_when_a_commit_is_unknown() {
205        // No commits known on either side → crate-version string comparison.
206        assert!(!is_daemon_stale(Some("1.2.3"), None, "1.2.3", None));
207        assert!(is_daemon_stale(Some("1.2.2"), None, "1.2.3", None));
208        assert!(is_daemon_stale(Some("1.3.0"), None, "1.2.3", None));
209        // A daemon that advertises neither version nor commit is never stale.
210        assert!(!is_daemon_stale(None, None, "1.2.3", None));
211        // One side missing a commit still falls back to the version compare.
212        assert!(is_daemon_stale(Some("1.2.2"), None, "1.2.3", Some("aaaa")));
213        assert!(is_daemon_stale(Some("1.2.2"), Some("bbbb"), "1.2.3", None));
214    }
215
216    #[test]
217    fn is_daemon_stale_compares_on_commit_when_both_are_known() {
218        // Same crate version, different commit → stale (the #1374 blind-spot fix).
219        assert!(is_daemon_stale(
220            Some("1.2.3"),
221            Some("bbbbbbbb"),
222            "1.2.3",
223            Some("aaaaaaaa")
224        ));
225        // Same commit → not stale, regardless of any version noise.
226        assert!(!is_daemon_stale(
227            Some("1.2.3"),
228            Some("aaaaaaaa"),
229            "1.2.3",
230            Some("aaaaaaaa")
231        ));
232    }
233
234    #[test]
235    fn services_flag_parses_a_subset_on_every_launcher() {
236        use crate::daemon::DaemonServiceKind;
237
238        let DaemonSubcommands::Run(cmd) = parse(&["run", "--services", "worktrees,sessions"])
239        else {
240            panic!("expected run");
241        };
242        assert_eq!(
243            cmd.services,
244            vec![DaemonServiceKind::Worktrees, DaemonServiceKind::Sessions]
245        );
246
247        let DaemonSubcommands::Start(cmd) = parse(&["start", "--services", "browser-bridge"])
248        else {
249            panic!("expected start");
250        };
251        assert_eq!(cmd.services, vec![DaemonServiceKind::Bridge]);
252
253        let DaemonSubcommands::Restart(cmd) = parse(&["restart", "--services", "snowflake"]) else {
254            panic!("expected restart");
255        };
256        assert_eq!(cmd.services, vec![DaemonServiceKind::Snowflake]);
257    }
258
259    #[test]
260    fn services_flag_defaults_empty_and_rejects_unknown_values() {
261        let DaemonSubcommands::Run(cmd) = parse(&["run"]) else {
262            panic!("expected run");
263        };
264        assert!(cmd.services.is_empty());
265
266        // An unknown service name is rejected by clap at parse time (a typo fails
267        // loudly instead of silently baking a name the daemon can't host).
268        assert!(Wrapper::try_parse_from(["omni-dev", "run", "--services", "bogus"]).is_err());
269    }
270
271    #[test]
272    fn warn_version_mismatch_covers_every_branch_without_panicking() {
273        // Exercises the print branch (a commit mismatch) and the no-op branches
274        // (matching, and a daemon advertising nothing). It writes to stderr, so
275        // there is nothing to assert beyond it not panicking.
276        let mismatch = StatusReport {
277            version: Some("0.0.0-mismatch".to_string()),
278            provenance: crate::build_info::Provenance {
279                commit: Some("deadbeef".to_string()),
280                commit_long: Some("deadbeefdeadbeef".to_string()),
281                ..crate::build_info::Provenance::default()
282            },
283            ..StatusReport::default()
284        };
285        warn_version_mismatch(&mismatch);
286        warn_version_mismatch(&StatusReport::current(vec![]));
287        warn_version_mismatch(&StatusReport::default());
288    }
289
290    #[tokio::test]
291    async fn call_service_returns_the_payload_of_an_ok_reply() {
292        let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
293            serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
294        );
295        let payload = call_service(&sock, "browser-bridge", "restart", serde_json::Value::Null)
296            .await
297            .unwrap();
298        assert_eq!(payload, serde_json::json!({ "restarted": true }));
299        server.await.unwrap();
300    }
301
302    #[tokio::test]
303    async fn daemon_execute_routes_logs_without_a_daemon() {
304        // The `Logs` dispatch arm reads the log file directly (no socket), so a
305        // missing `daemon.log` beside an absent socket is a clean no-op.
306        let dir = tempfile::tempdir().unwrap();
307        let cmd = DaemonCommand {
308            command: DaemonSubcommands::Logs(logs::LogsCommand {
309                socket: Some(dir.path().join("daemon.sock")),
310                lines: 10,
311                follow: false,
312            }),
313        };
314        cmd.execute().await.unwrap();
315    }
316
317    #[tokio::test]
318    async fn daemon_execute_routes_bridge_and_service_over_the_socket() {
319        // The `Bridge` and `Service` dispatch arms both reach a service over the
320        // control socket; a fake daemon acknowledges each.
321        let (_bdir, bsock, bserver) = crate::daemon::testutil::fake_daemon_reply(
322            serde_json::json!({ "ok": true, "payload": { "restarted": true } }),
323        );
324        DaemonCommand {
325            command: DaemonSubcommands::Bridge(bridge::BridgeCommand {
326                command: bridge::BridgeSubcommands::Restart(bridge::SocketArg {
327                    socket: Some(bsock),
328                }),
329            }),
330        }
331        .execute()
332        .await
333        .unwrap();
334        bserver.await.unwrap();
335
336        let (_sdir, ssock, sserver) = crate::daemon::testutil::fake_daemon_reply(
337            serde_json::json!({ "ok": true, "payload": { "connected": false } }),
338        );
339        DaemonCommand {
340            command: DaemonSubcommands::Service(service::ServiceCommand {
341                service: "browser-bridge".to_string(),
342                op: "status".to_string(),
343                payload: None,
344                socket: Some(ssock),
345            }),
346        }
347        .execute()
348        .await
349        .unwrap();
350        sserver.await.unwrap();
351    }
352
353    #[tokio::test]
354    async fn call_service_maps_an_error_reply_to_an_err() {
355        let (_dir, sock, server) = crate::daemon::testutil::fake_daemon_reply(
356            serde_json::json!({ "ok": false, "error": "no connected tab with id 9" }),
357        );
358        let err = call_service(
359            &sock,
360            "browser-bridge",
361            "disconnect-tab",
362            serde_json::Value::Null,
363        )
364        .await
365        .unwrap_err();
366        assert!(
367            err.to_string().contains("no connected tab with id 9"),
368            "{err}"
369        );
370        server.await.unwrap();
371    }
372
373    #[test]
374    fn socket_override_parses() {
375        let DaemonSubcommands::Run(cmd) = parse(&["run", "--socket", "/tmp/x.sock"]) else {
376            panic!("expected run");
377        };
378        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/x.sock")));
379    }
380
381    #[test]
382    fn status_json_flag_parses() {
383        let DaemonSubcommands::Status(cmd) = parse(&["status", "--json"]) else {
384            panic!("expected status");
385        };
386        assert!(cmd.json);
387    }
388
389    #[test]
390    fn socket_defaults_to_none() {
391        let DaemonSubcommands::Status(cmd) = parse(&["status"]) else {
392            panic!("expected status");
393        };
394        assert!(cmd.socket.is_none());
395        assert!(!cmd.json);
396    }
397
398    /// `daemon status` against a socket with no daemon dispatches through to the
399    /// "not running" path (table and `--json`) without erroring or side effects.
400    #[tokio::test]
401    async fn status_dispatch_reports_not_running() {
402        let dir = tempfile::tempdir().unwrap();
403        let socket = dir.path().join("absent.sock");
404        for json in [false, true] {
405            let cmd = DaemonCommand {
406                command: DaemonSubcommands::Status(status::StatusCommand {
407                    socket: Some(socket.clone()),
408                    output: crate::cli::format::TableOrJson::Table,
409                    json,
410                }),
411            };
412            cmd.execute().await.unwrap();
413        }
414    }
415}