Skip to main content

leviath_cli/commands/
ps.rs

1//! `lev ps` - list the agents running in the shared-world daemon.
2//!
3//! Queries the daemon over its control socket and prints one line per run. The
4//! query + formatting cores are tested here; the socket-path resolution + connect
5//! live in the binary behind [`crate::dispatch::RiskyExecutors`].
6
7use anyhow::bail;
8use leviath_runtime::components::AgentStatus;
9use leviath_runtime::control_socket::{ControlClient, ControlResponse};
10
11/// Arguments for `lev ps` (none yet).
12#[derive(clap::Args, Debug, Clone, Default)]
13pub struct PsArgs {}
14
15/// A short human label for an agent status.
16fn status_label(status: &AgentStatus) -> &'static str {
17    match status {
18        AgentStatus::Idle => "idle",
19        AgentStatus::Active => "active",
20        AgentStatus::Waiting => "waiting",
21        AgentStatus::Complete => "complete",
22        AgentStatus::Error { .. } => "error",
23        AgentStatus::Cancelled => "cancelled",
24    }
25}
26
27/// Render a run listing as aligned `RUN  STATUS` lines (or a friendly note when
28/// empty).
29pub fn format_runs(runs: &[(String, AgentStatus)]) -> String {
30    if runs.is_empty() {
31        return "no agents running".to_string();
32    }
33    let width = runs.iter().map(|(id, _)| id.len()).max().unwrap_or(0);
34    runs.iter()
35        .map(|(id, status)| format!("{id:<width$}  {}", status_label(status)))
36        .collect::<Vec<_>>()
37        .join("\n")
38}
39
40/// Query the daemon for its runs and print the formatted listing.
41pub async fn send_list(client: &ControlClient) -> anyhow::Result<()> {
42    match client.list().await {
43        Ok(ControlResponse::List { runs }) => {
44            println!("{}", format_runs(&runs));
45            Ok(())
46        }
47        Ok(other) => bail!("unexpected daemon response: {other:?}"),
48        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
56    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
57    use tokio::task::JoinHandle;
58
59    #[test]
60    fn status_labels_cover_every_variant() {
61        assert_eq!(status_label(&AgentStatus::Idle), "idle");
62        assert_eq!(status_label(&AgentStatus::Active), "active");
63        assert_eq!(status_label(&AgentStatus::Waiting), "waiting");
64        assert_eq!(status_label(&AgentStatus::Complete), "complete");
65        assert_eq!(
66            status_label(&AgentStatus::Error {
67                message: "x".to_string()
68            }),
69            "error"
70        );
71        assert_eq!(status_label(&AgentStatus::Cancelled), "cancelled");
72    }
73
74    #[test]
75    fn format_runs_aligns_and_handles_empty() {
76        assert_eq!(format_runs(&[]), "no agents running");
77        let runs = vec![
78            ("run-a".to_string(), AgentStatus::Active),
79            ("longer-run".to_string(), AgentStatus::Complete),
80        ];
81        let out = format_runs(&runs);
82        assert!(out.contains("run-a"));
83        assert!(out.contains("active"));
84        assert!(out.contains("longer-run  complete"));
85    }
86
87    /// Bind a control listener at a fresh id under `dir` and serve one canned
88    /// response, returning the id clients connect to and the server task.
89    fn fake_daemon(
90        dir: &std::path::Path,
91        response_line: &'static str,
92    ) -> (ControlId, JoinHandle<()>) {
93        let id = control_id(dir);
94        let mut listener = bind_control_listener(&id).unwrap();
95        let handle = tokio::spawn(async move {
96            let stream = listener
97                .accept()
98                .await
99                .expect("accept succeeds")
100                .expect("our own connection is admitted");
101            let (read_half, mut write_half) = tokio::io::split(stream);
102            let mut lines = BufReader::new(read_half).lines();
103            let _request = lines.next_line().await.unwrap();
104            write_half
105                .write_all(response_line.as_bytes())
106                .await
107                .unwrap();
108            write_half.write_all(b"\n").await.unwrap();
109        });
110        (id, handle)
111    }
112
113    async fn list(response_line: &'static str) -> anyhow::Result<()> {
114        let dir = tempfile::tempdir().unwrap();
115        let (id, server) = fake_daemon(dir.path(), response_line);
116        let result = send_list(&ControlClient::new(id)).await;
117        server.await.unwrap();
118        result
119    }
120
121    #[tokio::test]
122    async fn send_list_prints_runs() {
123        assert!(
124            list(r#"{"result":"list","runs":[["run-a","Active"]]}"#)
125                .await
126                .is_ok()
127        );
128    }
129
130    #[tokio::test]
131    async fn send_list_rejects_unexpected_response() {
132        let err = list(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
133        assert!(err.to_string().contains("unexpected"));
134    }
135
136    #[tokio::test]
137    async fn send_list_errors_when_daemon_absent() {
138        let dir = tempfile::tempdir().unwrap();
139        let err = send_list(&ControlClient::new(control_id(
140            &dir.path().join("no-daemon"),
141        )))
142        .await
143        .unwrap_err();
144        assert!(err.to_string().contains("not reachable"));
145    }
146}