Skip to main content

leviath_cli/commands/
daemon.rs

1//! `lev daemon` - run and manage the shared-world daemon.
2//!
3//! With no action, `lev daemon` runs the daemon in the foreground: it binds the
4//! control socket, drives the one shared world, and (on restart) reloads any
5//! agents persisted under the runs directory. That execution - binding a real
6//! socket, spawning a detached process, polling for readiness - is real I/O
7//! routed through [`crate::dispatch::RiskyExecutors`] and implemented by the
8//! binary (`main.rs`). This module defines the arguments plus the testable
9//! request/formatting cores the binary composes.
10
11use anyhow::bail;
12use leviath_runtime::control_socket::{ControlClient, ControlResponse};
13
14/// Arguments for `lev daemon`.
15#[derive(clap::Args, Debug, Clone, Default)]
16pub struct DaemonArgs {
17    /// Lifecycle action. Omitted, `lev daemon` runs the daemon in the foreground.
18    #[command(subcommand)]
19    pub action: Option<DaemonAction>,
20    /// Override the control socket / pipe (default: `<leviath-home>/.leviath`).
21    #[arg(long, global = true)]
22    pub socket: Option<String>,
23}
24
25/// Lifecycle actions for the shared-world daemon.
26#[derive(clap::Subcommand, Debug, Clone, PartialEq, Eq)]
27pub enum DaemonAction {
28    /// Start the daemon in the background (a no-op if one is already running).
29    Start,
30    /// Shut the running daemon down.
31    Stop,
32    /// Report whether the daemon is running and how many agents it hosts.
33    Status,
34    /// Restart the daemon (stop, then start) - reloading persisted agents.
35    Restart,
36    /// Register the daemon with the OS supervisor (launchd / systemd --user) so
37    /// it starts at login and is restarted automatically if it ever dies.
38    Install,
39    /// Deregister the daemon from the OS supervisor.
40    Uninstall,
41}
42
43/// Ask the daemon to shut down and report the outcome.
44pub async fn send_shutdown(client: &ControlClient) -> anyhow::Result<()> {
45    match client.shutdown().await {
46        Ok(ControlResponse::Ok { ok: true }) => {
47            println!("daemon shutting down");
48            Ok(())
49        }
50        Ok(other) => bail!("unexpected daemon response: {other:?}"),
51        Err(e) => bail!("the leviath daemon is not reachable ({e}); is it running?"),
52    }
53}
54
55/// The `lev daemon status` line for a `running` daemon hosting `run_count` agents.
56pub fn format_status(running: bool, run_count: usize) -> String {
57    if !running {
58        return "daemon not running".to_string();
59    }
60    let plural = if run_count == 1 { "" } else { "s" };
61    format!("daemon running ({run_count} agent{plural})")
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
68    use tokio::task::JoinHandle;
69
70    #[test]
71    fn format_status_covers_running_singular_plural_and_stopped() {
72        assert_eq!(format_status(false, 0), "daemon not running");
73        assert_eq!(format_status(true, 1), "daemon running (1 agent)");
74        assert_eq!(format_status(true, 3), "daemon running (3 agents)");
75    }
76
77    /// Bind a control listener at a fresh id under `dir` and serve one canned
78    /// response, returning the id clients connect to and the server task.
79    fn fake_daemon(
80        dir: &std::path::Path,
81        response_line: &'static str,
82    ) -> (leviath_runtime::control_socket::ControlId, JoinHandle<()>) {
83        let id = leviath_runtime::control_socket::control_id(dir);
84        let mut listener = leviath_runtime::control_socket::bind_control_listener(&id).unwrap();
85        let handle = tokio::spawn(async move {
86            let stream = listener
87                .accept()
88                .await
89                .expect("accept succeeds")
90                .expect("our own connection is admitted");
91            let (read_half, mut write_half) = tokio::io::split(stream);
92            let mut lines = BufReader::new(read_half).lines();
93            let _request = lines.next_line().await.unwrap();
94            write_half
95                .write_all(response_line.as_bytes())
96                .await
97                .unwrap();
98            write_half.write_all(b"\n").await.unwrap();
99        });
100        (id, handle)
101    }
102
103    async fn shutdown(response_line: &'static str) -> anyhow::Result<()> {
104        let dir = tempfile::tempdir().unwrap();
105        let (id, server) = fake_daemon(dir.path(), response_line);
106        let result = send_shutdown(&ControlClient::new(id)).await;
107        server.await.unwrap();
108        result
109    }
110
111    #[tokio::test]
112    async fn send_shutdown_reports_success() {
113        assert!(shutdown(r#"{"result":"ok","ok":true}"#).await.is_ok());
114    }
115
116    #[tokio::test]
117    async fn send_shutdown_rejects_unexpected_response() {
118        let err = shutdown(r#"{"result":"spawned","run_id":"x"}"#)
119            .await
120            .unwrap_err();
121        assert!(err.to_string().contains("unexpected"));
122    }
123
124    #[tokio::test]
125    async fn send_shutdown_errors_when_daemon_absent() {
126        let dir = tempfile::tempdir().unwrap();
127        let id = leviath_runtime::control_socket::control_id(&dir.path().join("no-daemon"));
128        let err = send_shutdown(&ControlClient::new(id)).await.unwrap_err();
129        assert!(err.to_string().contains("not reachable"));
130    }
131}