diff --git a/Cargo.lock b/Cargo.lock index c9547c2..605a736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,35 +317,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -437,15 +408,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "digest" version = "0.10.7" @@ -488,15 +450,6 @@ dependencies = [ "syn", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "either" version = "1.16.0" @@ -1060,12 +1013,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -1331,12 +1278,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - [[package]] name = "num-traits" version = "0.2.19" @@ -1414,12 +1355,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1448,22 +1383,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - [[package]] name = "quinn" version = "0.11.9" @@ -1665,8 +1584,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "cookie", - "cookie_store", "futures-core", "futures-util", "http", @@ -2123,37 +2040,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 86465f1..87454a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ maestro-protocol = { path = "crates/maestro-protocol" } maestro-tui = { path = "crates/maestro-tui" } nix = { version = "0.29", default-features = false, features = ["term", "signal"] } rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rust-embed = "8" include_dir = "0.7" mime_guess = "2" diff --git a/crates/maestro-cli/src/attach.rs b/crates/maestro-cli/src/attach.rs index 2d9b42d..654b266 100644 --- a/crates/maestro-cli/src/attach.rs +++ b/crates/maestro-cli/src/attach.rs @@ -9,7 +9,7 @@ use std::os::fd::AsFd; use std::sync::Arc; use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{ClientControl, ConnectInfo}; +use maestro_protocol::ClientControl; use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_tungstenite::tungstenite::Message; @@ -34,11 +34,8 @@ pub enum AttachError { impl AttachError { /// True when the failure was an inability to reach the WS endpoint at the - /// transport level (connection refused). Used to fall back from the home - /// daemon broker to the legacy registry direct-attach path (NAP-244). - /// An HTTP-level rejection from a *reachable* daemon (e.g. the broker - /// returns 404 / 502) is deliberately NOT treated as unreachable — the - /// daemon answered, so we surface its error instead of masking it. + /// transport level (connection refused), as opposed to an HTTP-level + /// rejection from a daemon that did answer. pub fn is_unreachable(&self) -> bool { use std::io::ErrorKind; use tokio_tungstenite::tungstenite::Error as WsError; @@ -66,33 +63,14 @@ pub fn parse_detach_key(spec: &str) -> Result { } } -/// Build the attach WS URL from a `ConnectInfo` and session ID. -#[allow(clippy::result_large_err)] -pub fn attach_url(info: &ConnectInfo, session_id: Uuid) -> Result { - let base = info.daemon_url.trim_end_matches('/'); - if !(base.starts_with("ws://") || base.starts_with("wss://")) { - return Err(AttachError::InvalidDaemonUrl(info.daemon_url.clone())); - } - Ok(format!( - "{base}/sessions/{session_id}/stream?token={token}", - token = urlencoding::encode(&info.jwt) - )) -} - -/// Build the home-daemon broker attach WS URL from the daemon's HTTP base. +/// Build the attach WS URL from the daemon's HTTP base. /// -/// The broker endpoint (`WS /api/attach/:id`, NAP-243) lives on the daemon's -/// `bind_addr`, so we take the HTTP base (`http://127.0.0.1:7070`), swap the -/// scheme to its WS equivalent, and append the route. The daemon picks the -/// transport and dials the target (which verifies the registry-minted -/// pair-attach JWT — session binding, not a per-peer allow-list); the CLI just -/// pumps frames. +/// The endpoint (`WS /api/attach/:id`) lives on the daemon's `bind_addr`, so we +/// take the HTTP base (`http://127.0.0.1:7070`), swap the scheme to its WS +/// equivalent, and append the route. #[allow(clippy::result_large_err)] -pub fn broker_attach_url( - home_daemon_http_base: &str, - session_id: Uuid, -) -> Result { - let base = home_daemon_http_base.trim_end_matches('/'); +pub fn attach_url(daemon_http_base: &str, session_id: Uuid) -> Result { + let base = daemon_http_base.trim_end_matches('/'); let ws_base = if let Some(rest) = base.strip_prefix("https://") { format!("wss://{rest}") } else if let Some(rest) = base.strip_prefix("http://") { @@ -100,9 +78,7 @@ pub fn broker_attach_url( } else if base.starts_with("ws://") || base.starts_with("wss://") { base.to_string() } else { - return Err(AttachError::InvalidDaemonUrl( - home_daemon_http_base.to_string(), - )); + return Err(AttachError::InvalidDaemonUrl(daemon_http_base.to_string())); }; Ok(format!("{ws_base}/api/attach/{session_id}")) } @@ -151,24 +127,9 @@ fn current_winsize() -> Option<(u16, u16)> { Some((ws.ws_col, ws.ws_row)) } -/// Drive the attach loop until the client detaches or the remote closes. -/// -/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned; -/// `detach_byte` is the single byte that exits cleanly without killing the -/// session. -#[allow(clippy::result_large_err)] -pub async fn run_attach( - connect_info: ConnectInfo, - session_id: Uuid, - detach_byte: u8, -) -> Result<(), AttachError> { - let url = attach_url(&connect_info, session_id)?; - run_attach_url(&url, detach_byte).await -} - -/// Drive the attach loop against an already-built WS `url`. Shared by the -/// home-daemon broker path ([`broker_attach_url`]) and the legacy registry -/// direct-attach path ([`run_attach`]) — both speak the same PTY wire format. +/// Drive the attach loop against an already-built WS `url` ([`attach_url`]) +/// until the client detaches or the remote closes. `detach_byte` is the single +/// byte that exits cleanly without killing the session. #[allow(clippy::result_large_err)] pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> { let (ws, _resp) = tokio_tungstenite::connect_async(url).await?; @@ -273,7 +234,6 @@ pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachErro #[cfg(test)] mod tests { use super::*; - use maestro_protocol::ConnectInfo; use uuid::Uuid; #[test] @@ -297,64 +257,28 @@ mod tests { } #[test] - fn attach_url_uses_ws_path_and_token_query() { - let info = ConnectInfo { - daemon_url: "ws://10.0.0.1:7070".into(), - jwt: "abc.def-ghi_jkl".into(), - }; - let id = Uuid::from_u128(0x1234_5678); - let url = attach_url(&info, id).unwrap(); - assert!(url.starts_with("ws://10.0.0.1:7070/sessions/")); - assert!(url.contains(&id.to_string())); - assert!(url.contains("/stream?token=abc.def-ghi_jkl")); - } - - #[test] - fn attach_url_rejects_non_ws_scheme() { - let info = ConnectInfo { - daemon_url: "http://x".into(), - jwt: "t".into(), - }; - assert!(matches!( - attach_url(&info, Uuid::nil()), - Err(AttachError::InvalidDaemonUrl(_)) - )); - } - - #[test] - fn broker_attach_url_swaps_scheme_and_appends_route() { + fn attach_url_swaps_scheme_and_appends_route() { let id = Uuid::from_u128(0xabcd); assert_eq!( - broker_attach_url("http://127.0.0.1:7070", id).unwrap(), + attach_url("http://127.0.0.1:7070", id).unwrap(), format!("ws://127.0.0.1:7070/api/attach/{id}") ); assert_eq!( - broker_attach_url("https://laptop.lan:7070/", id).unwrap(), + attach_url("https://laptop.lan:7070/", id).unwrap(), format!("wss://laptop.lan:7070/api/attach/{id}") ); // already-ws bases pass through assert_eq!( - broker_attach_url("ws://10.0.0.1:7070", id).unwrap(), + attach_url("ws://10.0.0.1:7070", id).unwrap(), format!("ws://10.0.0.1:7070/api/attach/{id}") ); } #[test] - fn broker_attach_url_rejects_unknown_scheme() { + fn attach_url_rejects_unknown_scheme() { assert!(matches!( - broker_attach_url("ftp://x", Uuid::nil()), + attach_url("ftp://x", Uuid::nil()), Err(AttachError::InvalidDaemonUrl(_)) )); } - - #[test] - fn attach_url_trims_trailing_slash() { - let info = ConnectInfo { - daemon_url: "wss://reg.example.com/".into(), - jwt: "t".into(), - }; - let id = Uuid::nil(); - let url = attach_url(&info, id).unwrap(); - assert!(!url.contains("//sessions"), "{url}"); - } } diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index fb7265e..660f494 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -9,14 +9,15 @@ use uuid::Uuid; #[command( name = "maestro", version, - about = "Control terminal sessions across one or more maestrod hosts", + about = "Control terminal sessions on the local maestro daemon", arg_required_else_help = true )] pub struct Cli { - /// Override the registry URL for this invocation (e.g. `https://maestro.example.com`). - /// If omitted, `[shared].registry_url` from the unified config file is used. - #[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")] - pub registry_url: Option, + /// Override the daemon HTTP base for this invocation (e.g. + /// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the + /// unified config file is used. + #[arg(long, global = true, env = "MAESTRO_DAEMON_URL")] + pub daemon_url: Option, /// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`. #[arg(long, global = true, env = "MAESTRO_CONFIG")] @@ -32,23 +33,18 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { - /// List all sessions, grouped by daemon. + /// List all sessions. List(ListArgs), - /// Spawn a new session on a given daemon. + /// Spawn a new session. Spawn(SpawnArgs), /// Attach to a session over WebSocket. Bytes flow through the local TTY. Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), - /// Set the default registry URL persisted in the CLI config. - SetRegistry(SetRegistryArgs), } #[derive(Debug, clap::Args)] pub struct ListArgs { - /// Show only sessions on this daemon. - #[arg(long)] - pub daemon: Option, /// Output as JSON instead of a table. #[arg(long)] pub json: bool, @@ -56,9 +52,6 @@ pub struct ListArgs { #[derive(Debug, Clone, clap::Args)] pub struct SpawnArgs { - /// Daemon ID to spawn on. - #[arg(long)] - pub daemon: String, /// Working directory for the session (must exist on the daemon's host). #[arg(long)] pub cwd: PathBuf, @@ -97,12 +90,6 @@ pub struct KillArgs { pub session_id: Uuid, } -#[derive(Debug, clap::Args)] -pub struct SetRegistryArgs { - /// Registry base URL to persist. - pub url: String, -} - fn parse_kv(s: &str) -> Result<(String, String), String> { match s.split_once('=') { Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())), @@ -117,10 +104,9 @@ mod tests { #[test] fn parses_list() { - let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap(); + let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap(); match cli.command { Command::List(args) => { - assert_eq!(args.daemon.as_deref(), Some("laptop")); assert!(args.json); } _ => panic!("wrong subcommand"), @@ -132,8 +118,6 @@ mod tests { let cli = Cli::try_parse_from([ "maestro", "spawn", - "--daemon", - "vm1", "--cwd", "/tmp", "--command", @@ -151,7 +135,6 @@ mod tests { .unwrap(); match cli.command { Command::Spawn(a) => { - assert_eq!(a.daemon, "vm1"); assert_eq!(a.command, "bash"); assert_eq!(a.branch.as_deref(), Some("feat/x")); assert!(a.cleanup_on_kill); @@ -169,8 +152,6 @@ mod tests { let err = Cli::try_parse_from([ "maestro", "spawn", - "--daemon", - "vm1", "--cwd", "/tmp", "--command", @@ -197,8 +178,6 @@ mod tests { let err = Cli::try_parse_from([ "maestro", "spawn", - "--daemon", - "x", "--cwd", "/", "--command", diff --git a/crates/maestro-cli/src/config.rs b/crates/maestro-cli/src/config.rs index c97bd60..4a112ec 100644 --- a/crates/maestro-cli/src/config.rs +++ b/crates/maestro-cli/src/config.rs @@ -1,8 +1,6 @@ //! CLI view over the unified Maestro config at `~/.config/maestro/config.toml`. //! -//! Reads `[shared].registry_url` (registry HTTP base) and `[cli].cookie` -//! (persisted `maestro_session` JWT). All other sections in the file are -//! preserved on write. +//! Reads `[cli].daemon_url` and, as a fallback, `[daemon].bind_addr`. use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -11,10 +9,10 @@ use maestro_config::MaestroConfigFile; pub use maestro_config::CONFIG_PATH_ENV; -/// Last-resort home-daemon HTTP base when neither config nor a local +/// Last-resort daemon HTTP base when neither config nor a local /// `[daemon].bind_addr` tells us otherwise. Matches the daemon's default -/// `bind_addr` (`crates/maestro-daemon` defaults). -pub const DEFAULT_HOME_DAEMON_URL: &str = "http://127.0.0.1:7070"; +/// `bind_addr`. +pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070"; /// `~/.config/maestro/config.toml` (XDG-aware). pub fn default_config_path() -> Result { @@ -30,80 +28,46 @@ pub fn resolve_config_path() -> Result { pub enum ConfigError { #[error(transparent)] Inner(#[from] maestro_config::ConfigError), - #[error( - "no registry URL configured — pass --registry-url, set \ - MAESTRO_REGISTRY_URL, or run `maestro set-registry `" - )] - NoRegistryUrl, } /// Resolved CLI config — the subset of the unified file that this binary /// actually consumes. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CliConfig { - pub registry_url: Option, - pub cookie: Option, - /// `[cli].home_daemon_url` — explicit home-daemon HTTP base (NAP-244). - pub home_daemon_url: Option, + /// `[cli].daemon_url` — explicit daemon HTTP base. + pub daemon_url: Option, /// `[daemon].bind_addr` if a daemon is configured on this host. Used to - /// derive the default home-daemon URL when `home_daemon_url` is unset. + /// derive the default daemon URL when `daemon_url` is unset. pub daemon_bind_addr: Option, } impl CliConfig { - /// Load the subset of the unified file the CLI consumes: - /// `[shared].registry_url`, `[cli].cookie`, `[cli].home_daemon_url`, and - /// `[daemon].bind_addr` (for default home-daemon derivation). Returns an - /// empty config if the file is missing — the CLI tolerates a missing file - /// so `maestro set-registry ` can bootstrap it. + /// Load the subset of the unified file the CLI consumes. A missing file is + /// fine — every field has a default. pub fn load(path: &Path) -> Result { let file = MaestroConfigFile::load_or_default(path)?; Ok(Self { - registry_url: file.shared.registry_url, - cookie: file.cli.cookie, - home_daemon_url: file.cli.home_daemon_url, + daemon_url: file.cli.daemon_url, daemon_bind_addr: file.daemon.bind_addr, }) } - /// Resolve the home-daemon HTTP base the broker path should target. + /// Resolve the daemon HTTP base to talk to. /// - /// Precedence: explicit `override_url` (CLI flag / env) → `[cli] - /// .home_daemon_url` → `http://<[daemon].bind_addr>` → the built-in - /// [`DEFAULT_HOME_DAEMON_URL`]. Always returns a value; reachability is - /// decided later by whether the connection actually succeeds. - pub fn resolve_home_daemon_url(&self, override_url: Option) -> String { + /// Precedence: explicit `override_url` (CLI flag / env) → `[cli].daemon_url` + /// → `http://<[daemon].bind_addr>` → [`DEFAULT_DAEMON_URL`]. Always returns + /// a value; reachability is decided by whether the connection succeeds. + pub fn resolve_daemon_url(&self, override_url: Option) -> String { override_url - .or_else(|| self.home_daemon_url.clone()) + .or_else(|| self.daemon_url.clone()) .or_else(|| self.daemon_bind_addr.map(|a| format!("http://{a}"))) - .unwrap_or_else(|| DEFAULT_HOME_DAEMON_URL.to_string()) - } - - /// Persist `[shared].registry_url`. Read-modify-write — preserves - /// any other sections already in the file (`[daemon]`, `[registry]`, - /// existing `[cli].cookie`). - pub fn save_registry_url(path: &Path, url: &str) -> Result<(), ConfigError> { - let mut file = MaestroConfigFile::load_or_default(path)?; - file.shared.registry_url = Some(url.to_string()); - file.save(path)?; - Ok(()) - } - - /// Persist `[cli].cookie`. Same read-modify-write semantics as - /// [`save_registry_url`]; in particular this does not touch - /// `[shared].registry_url`. - pub fn save_cookie(path: &Path, cookie: &str) -> Result<(), ConfigError> { - let mut file = MaestroConfigFile::load_or_default(path)?; - file.cli.cookie = Some(cookie.to_string()); - file.save(path)?; - Ok(()) + .unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string()) } } #[cfg(test)] mod tests { use super::*; - use maestro_config::SharedSection; fn write_file(path: &Path, body: &str) { std::fs::write(path, body).unwrap(); @@ -117,134 +81,48 @@ mod tests { } #[test] - fn load_reads_shared_registry_url_and_cli_cookie() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - write_file( - &path, - r#" -[shared] -registry_url = "https://r.example" - -[cli] -cookie = "eyJ..." -"#, - ); - let cfg = CliConfig::load(&path).unwrap(); - assert_eq!(cfg.registry_url.as_deref(), Some("https://r.example")); - assert_eq!(cfg.cookie.as_deref(), Some("eyJ...")); - } - - #[test] - fn save_registry_url_preserves_other_sections() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - write_file( - &path, - r#" -[shared] -token = "abc" - -[daemon] -daemon_url = "ws://laptop:7070" -"#, - ); - CliConfig::save_registry_url(&path, "https://new.example/").unwrap(); - let after = MaestroConfigFile::load(&path).unwrap(); - assert_eq!( - after.shared.registry_url.as_deref(), - Some("https://new.example/") - ); - assert_eq!(after.shared.token.as_deref(), Some("abc")); - assert_eq!(after.daemon.daemon_url.as_deref(), Some("ws://laptop:7070")); - } - - #[test] - fn save_cookie_does_not_touch_registry_url() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - write_file( - &path, - r#" -[shared] -registry_url = "https://r.example" -"#, - ); - CliConfig::save_cookie(&path, "eyJ.new").unwrap(); - let after = MaestroConfigFile::load(&path).unwrap(); - assert_eq!( - after.shared.registry_url.as_deref(), - Some("https://r.example") - ); - assert_eq!(after.cli.cookie.as_deref(), Some("eyJ.new")); - } - - #[test] - fn resolve_home_daemon_url_precedence() { + fn resolve_daemon_url_precedence() { // override wins over everything let cfg = CliConfig { - home_daemon_url: Some("http://from-cli-section:7070".into()), + daemon_url: Some("http://from-cli-section:7070".into()), daemon_bind_addr: Some("127.0.0.1:9999".parse().unwrap()), - ..Default::default() }; assert_eq!( - cfg.resolve_home_daemon_url(Some("http://override:1".into())), + cfg.resolve_daemon_url(Some("http://override:1".into())), "http://override:1" ); - // [cli].home_daemon_url next - assert_eq!( - cfg.resolve_home_daemon_url(None), - "http://from-cli-section:7070" - ); + // [cli].daemon_url next + assert_eq!(cfg.resolve_daemon_url(None), "http://from-cli-section:7070"); // derive from [daemon].bind_addr when no explicit url let cfg = CliConfig { daemon_bind_addr: Some("10.0.0.7:7070".parse().unwrap()), ..Default::default() }; - assert_eq!(cfg.resolve_home_daemon_url(None), "http://10.0.0.7:7070"); + assert_eq!(cfg.resolve_daemon_url(None), "http://10.0.0.7:7070"); // built-in default when nothing is configured let cfg = CliConfig::default(); - assert_eq!(cfg.resolve_home_daemon_url(None), DEFAULT_HOME_DAEMON_URL); + assert_eq!(cfg.resolve_daemon_url(None), DEFAULT_DAEMON_URL); } #[test] - fn load_reads_home_daemon_url_and_daemon_bind_addr() { + fn load_reads_daemon_url_and_daemon_bind_addr() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); write_file( &path, r#" [cli] -home_daemon_url = "http://laptop:7070" +daemon_url = "http://laptop:7070" [daemon] bind_addr = "127.0.0.1:7071" "#, ); let cfg = CliConfig::load(&path).unwrap(); - assert_eq!(cfg.home_daemon_url.as_deref(), Some("http://laptop:7070")); + assert_eq!(cfg.daemon_url.as_deref(), Some("http://laptop:7070")); assert_eq!( cfg.daemon_bind_addr, Some("127.0.0.1:7071".parse().unwrap()) ); } - - #[test] - fn save_creates_file_if_missing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nested/config.toml"); - CliConfig::save_registry_url(&path, "https://x").unwrap(); - assert!(path.exists()); - let after = MaestroConfigFile::load(&path).unwrap(); - assert_eq!( - after, - MaestroConfigFile { - shared: SharedSection { - registry_url: Some("https://x".into()), - token: None, - }, - ..Default::default() - } - ); - } } diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index 0bc1647..61cd0d1 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -1,33 +1,16 @@ //! Public dispatch for the `maestro` CLI subcommands. //! -//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro` -//! binary can dispatch to the same logic. -//! -//! ## Transport (NAP-244) -//! -//! The CLI talks to its **home daemon** (`[cli].home_daemon_url`, default -//! `http://127.0.0.1:7070`) and lets the daemon broker the rest: -//! -//! * `list` reads the daemon's local catalog mirror (own ∪ peer sessions). -//! * `spawn` / `kill` hit the daemon's local session endpoints. -//! * `attach` opens the daemon's `WS /api/attach/:id` broker, which picks the -//! transport and dials the target (the target verifies the registry-minted -//! pair-attach JWT — session binding, not a per-peer allow-list). -//! -//! If the home daemon is **unreachable** (connection refused / not running), -//! every command falls back to the legacy registry path (registry HTTP + -//! direct WS) with a one-line stderr warning, so migration is painless. As of -//! NAP-327 the daemon brokers cross-daemon create *and* kill over the registry -//! control link, so those no longer need a fallback — only an unreachable home -//! daemon does. +//! Every command talks to the local daemon (`[cli].daemon_url`, default +//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill` +//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`. use std::path::{Path, PathBuf}; -use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url}; -use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SetRegistryArgs, SpawnArgs}; +use crate::attach::{attach_url, parse_detach_key, run_attach_url}; +use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SpawnArgs}; use crate::config::{default_config_path, CliConfig, ConfigError}; -use crate::http::{HttpError, RegistryClient}; -use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn}; +use crate::http::DaemonClient; +use crate::sessions::{run_kill, run_list, run_spawn}; #[derive(Debug, thiserror::Error)] #[allow(clippy::result_large_err)] @@ -43,14 +26,12 @@ pub enum CliError { } /// Dispatch a parsed top-level subcommand. The umbrella binary passes the -/// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in -/// alongside the variant. +/// global flags (`--config`, `--daemon-url`) in alongside the variant. #[allow(clippy::result_large_err)] pub async fn dispatch( command: Command, config: Option, - registry_url_override: Option, - home_daemon_url_override: Option, + daemon_url_override: Option, ) -> Result<(), CliError> { let config_path = match config { Some(p) => p, @@ -58,116 +39,42 @@ pub async fn dispatch( }; match command { - Command::SetRegistry(args) => set_registry(&config_path, args), - Command::List(args) => { - list( - &config_path, - registry_url_override, - home_daemon_url_override, - args, - ) - .await - } - Command::Spawn(args) => { - spawn( - &config_path, - registry_url_override, - home_daemon_url_override, - args, - ) - .await - } - Command::Attach(args) => { - attach( - &config_path, - registry_url_override, - home_daemon_url_override, - args, - ) - .await - } - Command::Kill(args) => { - kill( - &config_path, - registry_url_override, - home_daemon_url_override, - args, - ) - .await - } + Command::List(args) => list(&config_path, daemon_url_override, args).await, + Command::Spawn(args) => spawn(&config_path, daemon_url_override, args).await, + Command::Attach(args) => attach(&config_path, daemon_url_override, args).await, + Command::Kill(args) => kill(&config_path, daemon_url_override, args).await, } } -#[allow(clippy::result_large_err)] -fn set_registry(config_path: &Path, args: SetRegistryArgs) -> Result<(), CliError> { - let url = args.url.trim_end_matches('/'); - CliConfig::save_registry_url(config_path, url)?; - println!("registry URL saved to {}", config_path.display()); - Ok(()) -} - #[allow(clippy::result_large_err)] async fn list( config_path: &Path, - registry_url_override: Option, - home_daemon_url_override: Option, + daemon_url_override: Option, args: ListArgs, ) -> Result<(), CliError> { - let cfg = CliConfig::load(config_path)?; - let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); - let daemon = daemon_client(&home_url, config_path); + let daemon = client(config_path, daemon_url_override)?; let mut stdout = std::io::stdout().lock(); - - match run_list(&daemon, &args, &mut stdout).await { - Ok(()) => Ok(()), - Err(e) if e.is_unreachable() => { - warn_fallback(&home_url, &e); - let registry = registry_client(registry_url_override, &cfg, config_path)?; - run_list(®istry, &args, &mut stdout).await?; - Ok(()) - } - Err(e) => Err(e.into()), - } + run_list(&daemon, &args, &mut stdout).await?; + Ok(()) } #[allow(clippy::result_large_err)] async fn spawn( config_path: &Path, - registry_url_override: Option, - home_daemon_url_override: Option, + daemon_url_override: Option, args: SpawnArgs, ) -> Result<(), CliError> { - let cfg = CliConfig::load(config_path)?; - let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone()); - let daemon = daemon_client(&home_url, config_path); + let daemon = client(config_path, daemon_url_override)?; let attach_after = args.attach; // `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q). let detach_key = 0x11; - // The home daemon now brokers cross-daemon create over the registry - // control link (NAP-327), so the only reason to fall back to the legacy - // registry HTTP path is the home daemon being unreachable. - let session = match run_spawn(&daemon, args.clone()).await { - Ok(s) => s, - Err(e) if e.is_unreachable() => { - warn_fallback(&home_url, &e); - let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?; - run_spawn(®istry, args).await? - } - Err(e) => return Err(e.into()), - }; + let session = run_spawn(&daemon, args).await?; println!("spawned {}", session.id); if attach_after { - attach_session( - &home_url, - registry_url_override, - &cfg, - config_path, - session.id, - detach_key, - ) - .await?; + let url = attach_url(daemon.base(), session.id)?; + run_attach_url(&url, detach_key).await?; } Ok(()) } @@ -175,105 +82,32 @@ async fn spawn( #[allow(clippy::result_large_err)] async fn attach( config_path: &Path, - registry_url_override: Option, - home_daemon_url_override: Option, + daemon_url_override: Option, args: AttachArgs, ) -> Result<(), CliError> { - let cfg = CliConfig::load(config_path)?; - let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); + let daemon = client(config_path, daemon_url_override)?; let detach = parse_detach_key(&args.detach_key)?; - attach_session( - &home_url, - registry_url_override, - &cfg, - config_path, - args.session_id, - detach, - ) - .await + let url = attach_url(daemon.base(), args.session_id)?; + run_attach_url(&url, detach).await?; + Ok(()) } #[allow(clippy::result_large_err)] async fn kill( config_path: &Path, - registry_url_override: Option, - home_daemon_url_override: Option, + daemon_url_override: Option, args: KillArgs, ) -> Result<(), CliError> { - let cfg = CliConfig::load(config_path)?; - let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override); - let daemon = daemon_client(&home_url, config_path); - - // The home daemon brokers cross-daemon kills too (NAP-327) and 404s only on - // a session no daemon knows about, so we no longer fall back on 404 — only - // when the home daemon itself is unreachable. - match run_kill(&daemon, &args).await { - Ok(()) => {} - Err(e) if e.is_unreachable() => { - warn_fallback(&home_url, &e); - let registry = registry_client(registry_url_override, &cfg, config_path)?; - run_kill(®istry, &args).await?; - } - Err(e) => return Err(e.into()), - } + let daemon = client(config_path, daemon_url_override)?; + run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); Ok(()) } -/// Attach to `session_id` through the home-daemon broker, falling back to the -/// registry's connect-info + direct WS path if the daemon is unreachable. +/// HTTP client pointed at the daemon's API. That surface needs no auth — +/// reaching `bind_addr` is the authorization. #[allow(clippy::result_large_err)] -async fn attach_session( - home_url: &str, - registry_url_override: Option, - cfg: &CliConfig, - config_path: &Path, - session_id: uuid::Uuid, - detach: u8, -) -> Result<(), CliError> { - let broker_url = broker_attach_url(home_url, session_id)?; - match run_attach_url(&broker_url, detach).await { - Ok(()) => Ok(()), - Err(e) if e.is_unreachable() => { - eprintln!( - "[maestro] home daemon at {home_url} unreachable; \ - falling back to the registry (legacy direct attach)" - ); - let registry = registry_client(registry_url_override, cfg, config_path)?; - let info = fetch_connect_info(®istry, session_id).await?; - run_attach(info, session_id, detach).await?; - Ok(()) - } - Err(e) => Err(e.into()), - } -} - -/// HTTP client pointed at the home daemon's local API. The daemon's local -/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent. -fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient { - RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf()) -} - -/// HTTP client pointed at the registry (legacy / fallback path). -#[allow(clippy::result_large_err)] -fn registry_client( - override_url: Option, - cfg: &CliConfig, - config_path: &Path, -) -> Result { - let url = override_url - .or_else(|| cfg.registry_url.clone()) - .ok_or(ConfigError::NoRegistryUrl)?; - Ok(RegistryClient::new( - url, - cfg.cookie.clone(), - config_path.to_path_buf(), - )) -} - -fn warn_fallback(home_url: &str, err: &HttpError) { - eprintln!( - "[maestro] home daemon at {home_url} unavailable ({err}); \ - falling back to the registry" - ); +fn client(config_path: &Path, override_url: Option) -> Result { + let cfg = CliConfig::load(config_path)?; + Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url))) } diff --git a/crates/maestro-cli/src/http.rs b/crates/maestro-cli/src/http.rs index 3290ad6..9964cc5 100644 --- a/crates/maestro-cli/src/http.rs +++ b/crates/maestro-cli/src/http.rs @@ -1,19 +1,14 @@ -//! Thin HTTP client wrapper around `reqwest` that persists the -//! `maestro_session` cookie issued by the registry's cookie middleware. +//! Thin HTTP client wrapper around `reqwest` for the daemon's `/api/*` surface. -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; use std::time::Duration; -use maestro_protocol::{extract_cookie, COOKIE_NAME}; -use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE}; use reqwest::{Method, Response, StatusCode}; use serde::de::DeserializeOwned; use serde::Serialize; -use crate::config::{CliConfig, ConfigError}; +use crate::config::ConfigError; -/// Time budget for establishing a TCP+TLS connection to the registry/daemon. +/// Time budget for establishing a TCP connection to the daemon. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); /// Total time budget for a single request (connect + send + receive). Bounds /// the case where the peer is TCP-alive but never makes forward progress. @@ -23,7 +18,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// /// Centralizing construction here guarantees every client the CLI uses gets a /// connect *and* request timeout, so `maestro` never hangs forever on an -/// unhealthy registry or daemon (NAP-262). +/// unhealthy daemon (NAP-262). pub fn build_client() -> reqwest::Client { build_client_with_timeouts(CONNECT_TIMEOUT, REQUEST_TIMEOUT) } @@ -47,41 +42,30 @@ pub enum HttpError { Json(#[from] serde_json::Error), #[error("config: {0}")] Config(#[from] ConfigError), - #[error("invalid header: {0}")] - InvalidHeader(#[from] reqwest::header::InvalidHeaderValue), } impl HttpError { /// True when the error is a transport-level failure to reach the host /// (connection refused / connect error) rather than an HTTP-level - /// rejection. The CLI uses this to decide whether to fall back from the - /// home daemon to the legacy registry path (NAP-244). + /// rejection. pub fn is_unreachable(&self) -> bool { matches!(self, HttpError::Reqwest(e) if e.is_connect()) } } -/// HTTP client bound to a single registry. Persists the auth cookie back to -/// disk when the registry sets one (first-contact bootstrap from -/// `crates/maestro-registry/src/auth.rs`). +/// HTTP client bound to one daemon's `/api/*` base URL. The daemon's surface +/// needs no auth — reaching its bind address is the authorization. #[derive(Debug)] -pub struct RegistryClient { +pub struct DaemonClient { base: String, inner: reqwest::Client, - /// Cookie value (the JWT body), shared with the config-persister. - cookie: Arc>>, - /// Where to write the config back when the cookie changes. - config_path: PathBuf, } -impl RegistryClient { - pub fn new(base: String, cookie: Option, config_path: PathBuf) -> Self { - let inner = build_client(); +impl DaemonClient { + pub fn new(base: String) -> Self { Self { base: base.trim_end_matches('/').to_string(), - inner, - cookie: Arc::new(Mutex::new(cookie)), - config_path, + inner: build_client(), } } @@ -89,13 +73,6 @@ impl RegistryClient { &self.base } - /// Snapshot of the current cookie (mostly for tests). - pub fn current_cookie(&self) -> Option { - self.cookie.lock().unwrap().clone() - } - - /// Send a request, attaching the persisted cookie. If the response sets a - /// new cookie (first contact, or a rotation), persist it back to disk. async fn send( &self, method: Method, @@ -103,39 +80,11 @@ impl RegistryClient { body: Option, ) -> Result { let url = format!("{}{}", self.base, path); - let mut headers = HeaderMap::new(); - if let Some(c) = self.cookie.lock().unwrap().as_ref() { - headers.insert( - COOKIE, - HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?, - ); - } - let mut req = self.inner.request(method, &url).headers(headers); + let mut req = self.inner.request(method, &url); if let Some(b) = body { req = req.json(&b); } - let resp = req.send().await?; - self.capture_cookie(resp.headers())?; - Ok(resp) - } - - fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> { - let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else { - return Ok(()); - }; - let Some(val) = extract_cookie(raw, COOKIE_NAME) else { - return Ok(()); - }; - let mut guard = self.cookie.lock().unwrap(); - if guard.as_deref() == Some(val) { - return Ok(()); - } - *guard = Some(val.to_string()); - // Persist; on failure, log but don't abort the request. - if let Err(e) = CliConfig::save_cookie(&self.config_path, val) { - tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie"); - } - Ok(()) + Ok(req.send().await?) } pub async fn get_json(&self, path: &str) -> Result { diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index 5930e64..ece1f45 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -1,7 +1,4 @@ -//! `maestro` CLI — thin client for the registry/daemon API. -//! -//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol" -//! and the registry's `client_api.rs` for the HTTP surface this client targets. +//! `maestro` CLI — thin client for the local daemon's `/api/*` surface. #![deny(missing_debug_implementations)] diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index b3e6670..4bb3c6c 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -1,45 +1,46 @@ -//! `maestro list / spawn / kill` — registry HTTP calls + console formatting. +//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting. use std::collections::HashMap; use std::io::Write; -use maestro_protocol::{ - ActivityState, ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus, - WorktreeSpec, -}; +use maestro_protocol::{ActivityState, CreateSessionRequest, Session, SessionStatus, WorktreeSpec}; +use serde::Deserialize; use serde_json::Value; -use uuid::Uuid; use crate::cli::{KillArgs, ListArgs, SpawnArgs}; -use crate::http::{HttpError, RegistryClient}; +use crate::http::{DaemonClient, HttpError}; + +/// The bits of `GET /api/info` the CLI renders. Deserialized structurally so +/// the CLI doesn't depend on the daemon crate. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct DaemonInfo { + #[serde(default)] + pub hostname: String, + #[serde(default)] + pub home_dir: String, +} /// `maestro list` pub async fn run_list( - client: &RegistryClient, + client: &DaemonClient, args: &ListArgs, out: &mut impl Write, ) -> Result<(), HttpError> { - let mut sessions: Vec = client.get_json("/api/sessions").await?; - let daemons: Vec = client - .get_json::>("/api/daemons") - .await - .unwrap_or_default(); - if let Some(d) = &args.daemon { - sessions.retain(|s| &s.daemon_id == d); - } + let sessions: Vec = client.get_json("/api/sessions").await?; + let info: DaemonInfo = client.get_json("/api/info").await.unwrap_or_default(); if args.json { - let body = serde_json::json!({ "sessions": sessions, "daemons": daemons }); + let body = serde_json::json!({ "sessions": sessions }); writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); return Ok(()); } - print_session_table(out, &sessions, &daemons); + print_session_table(out, &sessions, &info); Ok(()) } /// `maestro spawn` -pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result { +pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result { let mut env: HashMap = HashMap::new(); for (k, v) in args.env.iter() { env.insert(k.clone(), v.clone()); @@ -57,7 +58,6 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result Result Result<(), HttpError> { +pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), HttpError> { client .delete(&format!("/api/sessions/{}", args.session_id)) .await } -/// `GET /api/sessions/:id/connect-info` -pub async fn fetch_connect_info( - client: &RegistryClient, - session_id: Uuid, -) -> Result { - client - .get_json(&format!("/api/sessions/{session_id}/connect-info")) - .await -} - #[allow(clippy::write_literal)] -fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) { +fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) { if sessions.is_empty() { - if daemons.is_empty() { - writeln!(out, "no sessions, no daemons connected").ok(); - } else { - writeln!( - out, - "no sessions ({} daemon{} connected)", - daemons.len(), - if daemons.len() == 1 { "" } else { "s" } - ) - .ok(); - } + writeln!(out, "no sessions").ok(); return; } - // Group by daemon, stable order. - let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new(); - for s in sessions { - if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) { - slot.1.push(s); - } else { - by_daemon.push((s.daemon_id.clone(), vec![s])); - } - } - for (_, group) in &mut by_daemon { - group.sort_by_key(|s| s.created_at); - } + let mut sorted: Vec<&Session> = sessions.iter().collect(); + sorted.sort_by_key(|s| s.created_at); - for (daemon_id, group) in &by_daemon { - let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id); - let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?"); - let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or(""); - writeln!(out, "\n{daemon_id} ({hostname})").ok(); + if !info.hostname.is_empty() { + writeln!(out, "\n{}", info.hostname).ok(); + } + writeln!( + out, + " {:<36} {:<8} {:<9} {:<28} {}", + "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" + ) + .ok(); + for s in sorted { + // The pane's live cwd if observed, else the spawn dir, home-abbreviated. + let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir); + let status = match s.status { + SessionStatus::Running => "running", + SessionStatus::Dead => "dead", + }; + let activity = match s.activity { + ActivityState::Active => "active", + ActivityState::Idle => "idle", + ActivityState::AwaitingInput => "awaiting", + }; + // Show the live foreground command alongside the spawn command when + // the daemon has observed one that differs (NAP-340). + let command = match &s.foreground { + Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), + _ => s.command.clone(), + }; writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {}", - "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" + " {:<36} {:<8} {:<9} {:<28} {command}", + s.id, status, activity, dir ) .ok(); - for s in group { - // The pane's live cwd if observed, else the spawn dir, home-abbreviated. - let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), home); - let status = match s.status { - SessionStatus::Running => "running", - SessionStatus::Dead => "dead", - }; - let activity = match s.activity { - ActivityState::Active => "active", - ActivityState::Idle => "idle", - ActivityState::AwaitingInput => "awaiting", - }; - // Show the live foreground command alongside the spawn command when - // the daemon has observed one that differs (NAP-340). - let command = match &s.foreground { - Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), - _ => s.command.clone(), - }; - writeln!( - out, - " {:<36} {:<8} {:<9} {:<28} {command}", - s.id, status, activity, dir - ) - .ok(); - } } } @@ -175,13 +142,14 @@ mod tests { use std::path::PathBuf; use chrono::{TimeZone, Utc}; - use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus}; + use maestro_protocol::{ActivityState, Session, SessionStatus}; use uuid::Uuid; - fn session(daemon: &str, cwd: &str, command: &str, id: u128) -> Session { + use super::DaemonInfo; + + fn session(cwd: &str, command: &str, id: u128) -> Session { Session { id: Uuid::from_u128(id), - daemon_id: daemon.into(), cwd: PathBuf::from(cwd), command: command.into(), env: HashMap::new(), @@ -195,37 +163,26 @@ mod tests { } } + fn info(home: &str) -> DaemonInfo { + DaemonInfo { + hostname: "naps-laptop".into(), + home_dir: home.into(), + } + } + #[test] - fn formats_grouped_table() { + fn formats_table() { let sessions = vec![ - session("laptop", "/home/naps/maestro", "claude", 1), - session("vm1", "/srv/work/api", "bash", 2), - session("laptop", "/home/naps/scratch", "nvim", 3), - ]; - let daemons = vec![ - DaemonInfo { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.1:7070".into(), - home_dir: "/home/naps".into(), - }, - DaemonInfo { - daemon_id: "vm1".into(), - hostname: "vm1.lan".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.2:7070".into(), - home_dir: String::new(), - }, + session("/home/naps/maestro", "claude", 1), + session("/srv/work/api", "bash", 2), ]; let mut buf = Vec::new(); - super::print_session_table(&mut buf, &sessions, &daemons); + super::print_session_table(&mut buf, &sessions, &info("/home/naps")); let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("laptop (naps-laptop)"), "{s}"); - assert!(s.contains("vm1 (vm1.lan)"), "{s}"); + assert!(s.contains("naps-laptop"), "{s}"); // Home dir collapses to ~ in the DIR column. assert!(s.contains("~/maestro"), "{s}"); - // No home dir → path shown verbatim. + // Outside the home dir → path shown verbatim. assert!(s.contains("/srv/work/api"), "{s}"); assert!(s.contains("running"), "{s}"); assert!(s.contains("ACTIVITY"), "{s}"); @@ -235,35 +192,21 @@ mod tests { #[test] fn live_cwd_overrides_spawn_dir_in_table() { - let mut s = session("laptop", "/home/naps/maestro", "bash", 1); + let mut s = session("/home/naps/maestro", "bash", 1); s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli")); - let daemons = vec![DaemonInfo { - daemon_id: "laptop".into(), - hostname: "h".into(), - version: "0.1.0".into(), - daemon_url: "ws://x".into(), - home_dir: "/home/naps".into(), - }]; let mut buf = Vec::new(); - super::print_session_table(&mut buf, &[s], &daemons); + super::print_session_table(&mut buf, &[s], &info("/home/naps")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}"); } #[test] fn shows_activity_and_foreground() { - let mut s1 = session("laptop", "/tmp", "bash", 1); + let mut s1 = session("/tmp", "bash", 1); s1.activity = ActivityState::AwaitingInput; s1.foreground = Some("claude".into()); // differs from spawn command - let daemons = vec![DaemonInfo { - daemon_id: "laptop".into(), - hostname: "h".into(), - version: "0.1.0".into(), - daemon_url: "ws://x".into(), - home_dir: String::new(), - }]; let mut buf = Vec::new(); - super::print_session_table(&mut buf, &[s1], &daemons); + super::print_session_table(&mut buf, &[s1], &info("")); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("awaiting"), "{out}"); // Live foreground surfaces next to the spawn command. @@ -271,27 +214,9 @@ mod tests { } #[test] - fn empty_sessions_with_no_daemons() { + fn empty_sessions() { let mut buf = Vec::new(); - super::print_session_table(&mut buf, &[], &[]); - assert_eq!( - String::from_utf8(buf).unwrap(), - "no sessions, no daemons connected\n" - ); - } - - #[test] - fn empty_sessions_with_daemons() { - let daemons = vec![DaemonInfo { - daemon_id: "laptop".into(), - hostname: "naps".into(), - version: "0.1.0".into(), - daemon_url: "ws://x".into(), - home_dir: String::new(), - }]; - let mut buf = Vec::new(); - super::print_session_table(&mut buf, &[], &daemons); - let s = String::from_utf8(buf).unwrap(); - assert!(s.contains("1 daemon connected"), "{s}"); + super::print_session_table(&mut buf, &[], &info("")); + assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n"); } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 77b6762..5b2f8e5 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -215,12 +215,7 @@ impl App { self.state.overlay = Some(OverlayKind::Help); } Action::OpenSpawnModal => { - // Rebuild spawn modal against the current catalog so the - // daemon dropdown is fresh. - let snap = self.catalog.snapshot(); self.spawn = SpawnModal { - daemons: snap.daemons, - daemon_idx: 0, cwd: String::new(), command: String::new(), focused_field: Default::default(), @@ -291,16 +286,8 @@ impl App { } async fn submit_spawn(&mut self) { - let daemon_id = match self.spawn.daemons.get(self.spawn.daemon_idx) { - Some(d) => d.daemon_id.clone(), - None => { - self.state.overlay = Some(OverlayKind::Toast("spawn: pick a daemon first".into())); - return; - } - }; let cwd = std::path::PathBuf::from(self.spawn.cwd.clone()); let req = CreateSessionRequest { - daemon_id, cwd, command: self.spawn.command.clone(), env: Default::default(), @@ -348,7 +335,6 @@ impl App { let snap = self.catalog.snapshot(); let attached: HashSet = self.state.tabs.iter().filter_map(|t| t.pane).collect(); let sidebar = Sidebar { - daemons: snap.daemons.clone(), sessions: snap.sessions.clone(), highlight: self.state.sidebar_highlight, attached, @@ -412,17 +398,11 @@ impl App { } } -/// Order the sidebar walks for `j`/`k`: each daemon's sessions, in the same -/// order [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight -/// movement in visual sync with what the user sees. +/// Order the sidebar walks for `j`/`k`: the sessions in the same order +/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in +/// visual sync with what the user sees. fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec { - let mut out = Vec::with_capacity(snap.sessions.len()); - for d in &snap.daemons { - for s in snap.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) { - out.push(s.id); - } - } - out + snap.sessions.iter().map(|s| s.id).collect() } fn ws_base(http: &str) -> String { @@ -498,43 +478,21 @@ mod tests { } #[tokio::test] - async fn open_spawn_modal_seeds_daemon_list() { - use maestro_protocol::{DaemonInfo, RegistryEvent}; + async fn open_spawn_modal_sets_overlay() { let mut app = make_app(); - let d = DaemonInfo { - daemon_id: "konishi".into(), - hostname: "konishi.lan".into(), - version: "test".into(), - daemon_url: "ws://k:7070".into(), - home_dir: String::new(), - }; - app.catalog.apply_event(RegistryEvent::Snapshot { - daemons: vec![d.clone()], - sessions: vec![], - }); app.dispatch(Action::OpenSpawnModal).await; assert!(matches!(app.state.overlay, Some(OverlayKind::Spawn))); - assert_eq!(app.spawn.daemons.len(), 1); - assert_eq!(app.spawn.daemons[0].daemon_id, "konishi"); } #[tokio::test] async fn kill_highlighted_opens_confirm() { use chrono::Utc; - use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus}; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; let mut app = make_app(); let sid = Uuid::from_u128(7); - app.catalog.apply_event(RegistryEvent::Snapshot { - daemons: vec![DaemonInfo { - daemon_id: "k".into(), - hostname: "k".into(), - version: "t".into(), - daemon_url: "ws://k".into(), - home_dir: String::new(), - }], + app.catalog.apply_event(CatalogEvent::Snapshot { sessions: vec![Session { id: sid, - daemon_id: "k".into(), command: "bash".into(), cwd: std::path::PathBuf::from("/"), env: Default::default(), diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 99936e4..2190ee4 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -1,10 +1,10 @@ -//! `CatalogStore` — the TUI's view of the daemons-and-sessions tree. +//! `CatalogStore` — the TUI's view of the session list. //! //! Backed entirely by the daemon's `WS /api/events` stream: //! -//! 1. The first frame after connect is a [`RegistryEvent::Snapshot`] — that's +//! 1. The first frame after connect is a [`CatalogEvent::Snapshot`] — that's //! the bootstrap. No separate REST round-trip needed. -//! 2. Subsequent frames are deltas (`DaemonAdded`, `SessionAdded`, …) that we +//! 2. Subsequent frames are deltas (`SessionAdded`, `SessionUpdated`, …) that we //! apply atomically and bump a monotonic version counter. //! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next //! connect resends a fresh `Snapshot` so we converge without needing @@ -21,7 +21,7 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; use futures_util::StreamExt; -use maestro_protocol::{DaemonInfo, RegistryEvent, Session}; +use maestro_protocol::{CatalogEvent, Session}; use tokio::sync::Notify; use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; @@ -31,7 +31,6 @@ use uuid::Uuid; /// needed. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CatalogSnapshot { - pub daemons: Vec, pub sessions: Vec, pub version: u64, } @@ -49,7 +48,6 @@ struct Inner { #[derive(Debug, Default)] struct State { - daemons: HashMap, sessions: HashMap, /// Bumped on every applied event. Wraps at u64::MAX, which is fine /// because comparisons are equality, not ordering. @@ -61,33 +59,20 @@ struct State { impl CatalogStore { /// Apply one event atomically. Used by tests directly and by the WS loop. - pub fn apply_event(&self, ev: RegistryEvent) { + pub fn apply_event(&self, ev: CatalogEvent) { let mut s = self.inner.state.write().expect("catalog rwlock poisoned"); match ev { - RegistryEvent::Snapshot { daemons, sessions } => { - s.daemons = daemons - .into_iter() - .map(|d| (d.daemon_id.clone(), d)) - .collect(); + CatalogEvent::Snapshot { sessions } => { s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect(); s.bootstrapped = true; } - RegistryEvent::DaemonAdded { daemon } => { - s.daemons.insert(daemon.daemon_id.clone(), daemon); - } - RegistryEvent::DaemonRemoved { daemon_id } => { - s.daemons.remove(&daemon_id); - // Drop sessions owned by the gone daemon — server will - // re-add them if it reappears. - s.sessions.retain(|_, sess| sess.daemon_id != daemon_id); - } - RegistryEvent::SessionAdded { session } | RegistryEvent::SessionUpdated { session } => { + CatalogEvent::SessionAdded { session } | CatalogEvent::SessionUpdated { session } => { s.sessions.insert(session.id, session); } - RegistryEvent::SessionRemoved { daemon_id: _, id } => { + CatalogEvent::SessionRemoved { id } => { s.sessions.remove(&id); } - RegistryEvent::Lagged { skipped } => { + CatalogEvent::Lagged { skipped } => { tracing::warn!( skipped, "catalog events stream lagged; resync via reconnect" @@ -101,15 +86,12 @@ impl CatalogStore { self.inner.notify.notify_waiters(); } - /// Cheap O(daemons + sessions) read of current state. + /// Cheap O(sessions) read of current state. pub fn snapshot(&self) -> CatalogSnapshot { let s = self.inner.state.read().expect("catalog rwlock poisoned"); - let mut daemons: Vec = s.daemons.values().cloned().collect(); - daemons.sort_by(|a, b| a.daemon_id.cmp(&b.daemon_id)); let mut sessions: Vec = s.sessions.values().cloned().collect(); sessions.sort_by_key(|x| x.id); CatalogSnapshot { - daemons, sessions, version: s.version, } @@ -141,7 +123,7 @@ impl CatalogStore { } } - /// Long-running task: connect to `ws_url`, parse `RegistryEvent` frames, + /// Long-running task: connect to `ws_url`, parse [`CatalogEvent`] frames, /// apply them. Reconnects with exponential backoff on disconnect. Never /// returns — spawn it on a Tokio task. pub async fn run_events_loop(self, ws_url: String) { @@ -154,7 +136,7 @@ impl CatalogStore { while let Some(frame) = stream.next().await { match frame { Ok(Message::Text(text)) => { - match serde_json::from_str::(&text) { + match serde_json::from_str::(&text) { Ok(ev) => self.apply_event(ev), Err(e) => { tracing::warn!(error = ?e, "catalog: bad event frame"); @@ -197,22 +179,11 @@ fn jitter(base: Duration) -> Duration { mod tests { use super::*; use chrono::Utc; - use maestro_protocol::{DaemonInfo, Session, SessionStatus}; + use maestro_protocol::{Session, SessionStatus}; - fn daemon(id: &str) -> DaemonInfo { - DaemonInfo { - daemon_id: id.into(), - hostname: format!("{id}.lan"), - version: "test".into(), - daemon_url: format!("ws://{id}.lan:7070"), - home_dir: String::new(), - } - } - - fn session(id: u128, daemon_id: &str, command: &str) -> Session { + fn session(id: u128, command: &str) -> Session { Session { id: Uuid::from_u128(id), - daemon_id: daemon_id.into(), command: command.into(), cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), @@ -222,7 +193,6 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), - current_cwd: None, } } @@ -230,82 +200,39 @@ mod tests { #[test] fn snapshot_event_replaces_state() { let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("konishi")], - sessions: vec![session(1, "konishi", "a")], + store.apply_event(CatalogEvent::Snapshot { + sessions: vec![session(1, "a")], }); let snap = store.snapshot(); - assert_eq!(snap.daemons.len(), 1); assert_eq!(snap.sessions.len(), 1); assert_eq!(snap.version, 1); assert!(store.is_bootstrapped()); // A second snapshot wipes out the old state. - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("yamato")], - sessions: vec![], - }); + store.apply_event(CatalogEvent::Snapshot { sessions: vec![] }); let snap = store.snapshot(); - assert_eq!(snap.daemons.len(), 1); - assert_eq!(snap.daemons[0].daemon_id, "yamato"); assert!(snap.sessions.is_empty()); assert_eq!(snap.version, 2); } - #[test] - fn daemon_added_then_removed() { - let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![], - sessions: vec![], - }); - store.apply_event(RegistryEvent::DaemonAdded { - daemon: daemon("konishi"), - }); - assert_eq!(store.snapshot().daemons.len(), 1); - store.apply_event(RegistryEvent::DaemonRemoved { - daemon_id: "konishi".into(), - }); - assert!(store.snapshot().daemons.is_empty()); - } - - #[test] - fn daemon_removed_evicts_its_sessions() { - let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("konishi"), daemon("yamato")], - sessions: vec![session(1, "konishi", "a"), session(2, "yamato", "b")], - }); - store.apply_event(RegistryEvent::DaemonRemoved { - daemon_id: "konishi".into(), - }); - let snap = store.snapshot(); - assert_eq!(snap.sessions.len(), 1); - assert_eq!(snap.sessions[0].daemon_id, "yamato"); - } - #[test] fn session_added_updated_removed() { let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("konishi")], - sessions: vec![], - }); - store.apply_event(RegistryEvent::SessionAdded { - session: session(7, "konishi", "vim"), + store.apply_event(CatalogEvent::Snapshot { sessions: vec![] }); + store.apply_event(CatalogEvent::SessionAdded { + session: session(7, "vim"), }); assert_eq!(store.snapshot().sessions.len(), 1); assert_eq!(store.snapshot().sessions[0].command, "vim"); - let mut updated = session(7, "konishi", "claude"); + let mut updated = session(7, "claude"); updated.status = SessionStatus::Dead; - store.apply_event(RegistryEvent::SessionUpdated { session: updated }); + store.apply_event(CatalogEvent::SessionUpdated { session: updated }); let snap = store.snapshot(); assert_eq!(snap.sessions[0].command, "claude"); assert!(matches!(snap.sessions[0].status, SessionStatus::Dead)); - store.apply_event(RegistryEvent::SessionRemoved { - daemon_id: "konishi".into(), + store.apply_event(CatalogEvent::SessionRemoved { id: Uuid::from_u128(7), }); assert!(store.snapshot().sessions.is_empty()); @@ -314,14 +241,12 @@ mod tests { #[test] fn lagged_event_doesnt_blow_up_state() { let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("konishi")], - sessions: vec![session(1, "konishi", "a")], + store.apply_event(CatalogEvent::Snapshot { + sessions: vec![session(1, "a")], }); let before = store.snapshot(); - store.apply_event(RegistryEvent::Lagged { skipped: 42 }); + store.apply_event(CatalogEvent::Lagged { skipped: 42 }); let after = store.snapshot(); - assert_eq!(after.daemons, before.daemons); assert_eq!(after.sessions, before.sessions); assert_eq!(after.version, before.version + 1); } @@ -329,17 +254,10 @@ mod tests { #[test] fn snapshot_is_sorted() { let store = CatalogStore::default(); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![daemon("yamato"), daemon("konishi"), daemon("aurora")], - sessions: vec![ - session(3, "aurora", "c"), - session(1, "konishi", "a"), - session(2, "yamato", "b"), - ], + store.apply_event(CatalogEvent::Snapshot { + sessions: vec![session(3, "c"), session(1, "a"), session(2, "b")], }); let snap = store.snapshot(); - let ids: Vec<_> = snap.daemons.iter().map(|d| d.daemon_id.as_str()).collect(); - assert_eq!(ids, vec!["aurora", "konishi", "yamato"]); let session_ids: Vec<_> = snap.sessions.iter().map(|s| s.id.as_u128()).collect(); assert_eq!(session_ids, vec![1, 2, 3]); } @@ -353,10 +271,7 @@ mod tests { }); tokio::time::sleep(Duration::from_millis(10)).await; assert!(!handle.is_finished()); - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![], - sessions: vec![], - }); + store.apply_event(CatalogEvent::Snapshot { sessions: vec![] }); // Give the waker a chance. tokio::time::timeout(Duration::from_millis(200), handle) .await @@ -372,10 +287,7 @@ mod tests { tokio::spawn(async move { s.changed().await }) }; tokio::time::sleep(Duration::from_millis(10)).await; - store.apply_event(RegistryEvent::Snapshot { - daemons: vec![], - sessions: vec![], - }); + store.apply_event(CatalogEvent::Snapshot { sessions: vec![] }); tokio::time::timeout(Duration::from_millis(200), waiter) .await .expect("changed did not fire") diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 27ec96a..6f4a9c0 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -47,7 +47,7 @@ pub struct RunConfig { #[derive(Debug, thiserror::Error)] pub enum TuiError { - #[error("daemon unreachable at {0}; try `maestro daemon configure` or check `systemctl --user status maestro-daemon`")] + #[error("daemon unreachable at {0}; check `systemctl --user status maestro-daemon`")] DaemonUnreachable(String), #[error("config: {0}")] Config(String), @@ -104,7 +104,7 @@ async fn health_check(base_url: &str) -> Result<(), TuiError> { .timeout(Duration::from_millis(500)) .build() .expect("reqwest client"); - let url = format!("{base_url}/api/web/info"); + let url = format!("{base_url}/api/info"); for _ in 0..3 { if let Ok(res) = client.get(&url).send().await { if res.status().is_success() { diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 054c0f1..b94a32e 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -4,7 +4,6 @@ //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. -use maestro_protocol::DaemonInfo; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::Line; @@ -14,7 +13,6 @@ use ratatui::Frame; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum SpawnField { #[default] - Daemon, Cwd, Command, Submit, @@ -22,8 +20,6 @@ pub enum SpawnField { #[derive(Debug, Default, Clone)] pub struct SpawnModal { - pub daemons: Vec, - pub daemon_idx: usize, pub cwd: String, pub command: String, pub focused_field: SpawnField, @@ -32,17 +28,15 @@ pub struct SpawnModal { impl SpawnModal { pub fn next_field(&mut self) { self.focused_field = match self.focused_field { - SpawnField::Daemon => SpawnField::Cwd, SpawnField::Cwd => SpawnField::Command, SpawnField::Command => SpawnField::Submit, - SpawnField::Submit => SpawnField::Daemon, + SpawnField::Submit => SpawnField::Cwd, }; } pub fn prev_field(&mut self) { self.focused_field = match self.focused_field { - SpawnField::Daemon => SpawnField::Submit, - SpawnField::Cwd => SpawnField::Daemon, + SpawnField::Cwd => SpawnField::Submit, SpawnField::Command => SpawnField::Cwd, SpawnField::Submit => SpawnField::Command, }; @@ -70,20 +64,6 @@ impl SpawnModal { match self.focused_field { SpawnField::Cwd => self.cwd.push(*c), SpawnField::Command => self.command.push(*c), - SpawnField::Daemon => { - if !self.daemons.is_empty() { - match c { - 'j' => { - self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len(); - } - 'k' => { - self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1) - % self.daemons.len(); - } - _ => {} - } - } - } SpawnField::Submit => {} } SpawnInputResult::Consumed @@ -105,13 +85,12 @@ impl SpawnModal { } pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 60, 12); + let area = centered(parent, 60, 10); f.render_widget(Clear, area); let block = Block::default().title(" spawn ").borders(Borders::ALL); let inner = block.inner(area); f.render_widget(block, area); let rows = Layout::vertical([ - Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), Constraint::Length(1), @@ -124,11 +103,6 @@ impl SpawnModal { Style::default() } }; - let daemon_label = self - .daemons - .get(self.daemon_idx) - .map(|d| d.hostname.as_str()) - .unwrap_or("(none)"); let field_row = |label: &str, value: &str, style: Style| { Paragraph::new(Line::from(vec![ ratatui::text::Span::raw(format!("{label:8} ")), @@ -136,23 +110,19 @@ impl SpawnModal { ])) }; f.render_widget( - field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)), + field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)), rows[0], ); - f.render_widget( - field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)), - rows[1], - ); f.render_widget( field_row("command:", &self.command, highlight(SpawnField::Command)), - rows[2], + rows[1], ); f.render_widget( Paragraph::new(Line::from(vec![ratatui::text::Span::styled( "[ Spawn ]", highlight(SpawnField::Submit), )])), - rows[3], + rows[2], ); // Place the cursor at the end of the currently-focused text field so @@ -165,12 +135,12 @@ impl SpawnModal { .saturating_add(value.chars().count() as u16) }; match self.focused_field { - SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[1].y)), + SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)), SpawnField::Command => { - f.set_cursor_position((cursor_col_for(&self.command), rows[2].y)) + f.set_cursor_position((cursor_col_for(&self.command), rows[1].y)) } - SpawnField::Daemon | SpawnField::Submit => { - // Non-text fields are indicated by the REVERSED style; no + SpawnField::Submit => { + // The submit button is indicated by the REVERSED style; no // cursor placement. } } @@ -293,20 +263,12 @@ mod tests { #[test] fn spawn_modal_renders_field_labels() { let modal = SpawnModal { - daemons: vec![DaemonInfo { - daemon_id: "k".into(), - hostname: "konishi".into(), - version: "0".into(), - daemon_url: "ws://x".into(), - home_dir: String::new(), - }], - daemon_idx: 0, cwd: "/x".into(), command: "bash".into(), focused_field: SpawnField::Cwd, }; let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); - for s in ["daemon", "cwd", "command", "Spawn", "konishi", "/x", "bash"] { + for s in ["cwd", "command", "Spawn", "/x", "bash"] { assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); } } @@ -314,15 +276,13 @@ mod tests { #[test] fn spawn_modal_field_rotation() { let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Daemon); - modal.next_field(); assert_eq!(modal.focused_field, SpawnField::Cwd); modal.next_field(); assert_eq!(modal.focused_field, SpawnField::Command); modal.next_field(); assert_eq!(modal.focused_field, SpawnField::Submit); modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Daemon); + assert_eq!(modal.focused_field, SpawnField::Cwd); modal.prev_field(); assert_eq!(modal.focused_field, SpawnField::Submit); } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index f8d2b93..e695ea7 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -1,10 +1,10 @@ -//! Sidebar widget — daemon→session tree, navigation-only. +//! Sidebar widget — session list, navigation-only. //! //! Status glyphs: ● attached / ○ live / ✗ dead. use std::collections::HashSet; -use maestro_protocol::{DaemonInfo, Session, SessionStatus}; +use maestro_protocol::{Session, SessionStatus}; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -15,7 +15,6 @@ use uuid::Uuid; /// Renderable sidebar state. #[derive(Debug, Clone)] pub struct Sidebar { - pub daemons: Vec, pub sessions: Vec, pub highlight: Option, pub attached: HashSet, @@ -43,28 +42,25 @@ impl Sidebar { f.render_widget(block, area); let mut lines: Vec = vec![Line::from(Span::styled( - "daemons", + "sessions", Style::default().add_modifier(Modifier::BOLD), ))]; - for d in &self.daemons { - lines.push(Line::from(format!("● {}", d.hostname))); - for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) { - let glyph = match (s.status, self.attached.contains(&s.id)) { - (SessionStatus::Dead, _) => "✗", - (SessionStatus::Running, true) => "●", - (SessionStatus::Running, false) => "○", - }; - let label = name_for(s); - let style = if self.highlight == Some(s.id) { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("{glyph} {label}"), style), - ])); - } + for s in &self.sessions { + let glyph = match (s.status, self.attached.contains(&s.id)) { + (SessionStatus::Dead, _) => "✗", + (SessionStatus::Running, true) => "●", + (SessionStatus::Running, false) => "○", + }; + let label = name_for(s); + let style = if self.highlight == Some(s.id) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{glyph} {label}"), style), + ])); } f.render_widget(Paragraph::new(lines), inner); } @@ -87,22 +83,11 @@ mod tests { use ratatui::Terminal; use std::collections::HashMap; - fn daemon(id: &str) -> DaemonInfo { - DaemonInfo { - daemon_id: id.into(), - hostname: id.into(), - version: "test".into(), - daemon_url: format!("ws://{id}:7070"), - home_dir: String::new(), - } - } - - fn session_named(id: u128, daemon_id: &str, name: &str) -> Session { + fn session_named(id: u128, name: &str) -> Session { let mut metadata = HashMap::new(); metadata.insert("name".into(), serde_json::Value::String(name.into())); Session { id: Uuid::from_u128(id), - daemon_id: daemon_id.into(), command: "bash".into(), cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), @@ -132,21 +117,16 @@ mod tests { } #[test] - fn renders_hostname_and_session_names() { + fn renders_session_names() { let sb = Sidebar { - daemons: vec![daemon("konishi")], sessions: vec![ - session_named(1, "konishi", "claude"), - session_named(2, "konishi", "bash"), + session_named(1, "claude"), + session_named(2, "bash"), ], highlight: None, attached: Default::default(), }; let rendered = render_to_string(&sb, 28, 12); - assert!( - rendered.contains("konishi"), - "missing daemon name:\n{rendered}" - ); assert!( rendered.contains("claude"), "missing session name:\n{rendered}" @@ -161,8 +141,7 @@ mod tests { fn attached_session_uses_filled_glyph() { let sid = Uuid::from_u128(1); let sb = Sidebar { - daemons: vec![daemon("konishi")], - sessions: vec![session_named(1, "konishi", "claude")], + sessions: vec![session_named(1, "claude")], highlight: None, attached: [sid].into_iter().collect(), }; @@ -180,10 +159,9 @@ mod tests { #[test] fn dead_session_uses_cross_glyph() { - let mut sess = session_named(1, "konishi", "claude"); + let mut sess = session_named(1, "claude"); sess.status = SessionStatus::Dead; let sb = Sidebar { - daemons: vec![daemon("konishi")], sessions: vec![sess], highlight: None, attached: Default::default(), @@ -200,7 +178,6 @@ mod tests { fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { id: Uuid::from_u128(1), - daemon_id: "konishi".into(), command: "vim".into(), cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), @@ -214,7 +191,6 @@ mod tests { current_cwd: None, }; let sb = Sidebar { - daemons: vec![daemon("konishi")], sessions: vec![s], highlight: None, attached: Default::default(), diff --git a/crates/maestro-tui/tests/catalog_ws.rs b/crates/maestro-tui/tests/catalog_ws.rs index a264830..f914a81 100644 --- a/crates/maestro-tui/tests/catalog_ws.rs +++ b/crates/maestro-tui/tests/catalog_ws.rs @@ -9,24 +9,13 @@ use axum::response::Response; use axum::routing::any; use axum::Router; use chrono::Utc; -use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus}; +use maestro_protocol::{CatalogEvent, Session, SessionStatus}; use maestro_tui::catalog::CatalogStore; use uuid::Uuid; -fn daemon(id: &str) -> DaemonInfo { - DaemonInfo { - daemon_id: id.into(), - hostname: format!("{id}.lan"), - version: "test".into(), - daemon_url: format!("ws://{id}.lan:7070"), - home_dir: String::new(), - } -} - -fn session(id: u128, daemon_id: &str) -> Session { +fn session(id: u128) -> Session { Session { id: Uuid::from_u128(id), - daemon_id: daemon_id.into(), command: "bash".into(), cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), @@ -43,18 +32,15 @@ fn session(id: u128, daemon_id: &str) -> Session { async fn events_handler(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket| async move { - let snapshot = RegistryEvent::Snapshot { - daemons: vec![daemon("konishi")], - sessions: vec![], - }; + let snapshot = CatalogEvent::Snapshot { sessions: vec![] }; let text = serde_json::to_string(&snapshot).unwrap(); if socket.send(Message::Text(text)).await.is_err() { return; } // Small delay so the client has the snapshot applied before the delta. tokio::time::sleep(Duration::from_millis(50)).await; - let added = RegistryEvent::SessionAdded { - session: session(1, "konishi"), + let added = CatalogEvent::SessionAdded { + session: session(1), }; let text = serde_json::to_string(&added).unwrap(); let _ = socket.send(Message::Text(text)).await; @@ -85,9 +71,7 @@ async fn run_events_loop_applies_snapshot_then_delta() { .await .expect("initial snapshot never arrived"); - let snap = store.snapshot(); - assert_eq!(snap.daemons.len(), 1); - assert_eq!(snap.daemons[0].daemon_id, "konishi"); + assert!(store.snapshot().sessions.is_empty()); // Delta arrives shortly after. Poll for up to 500 ms. for _ in 0..50 { diff --git a/crates/maestro/src/main.rs b/crates/maestro/src/main.rs index 1223cfb..96e956e 100644 --- a/crates/maestro/src/main.rs +++ b/crates/maestro/src/main.rs @@ -1,19 +1,13 @@ //! `maestro` — unified binary that hosts the CLI, the per-host daemon, and the -//! always-on registry behind feature-gated subcommands (NAP-245). +//! TUI behind feature-gated subcommands (NAP-245). //! //! ```text -//! cargo install --no-default-features --features registry maestro # registry server -//! cargo install --no-default-features --features daemon maestro # daemon-only host -//! cargo install maestro # default: cli + daemon + registry +//! cargo install --no-default-features --features daemon maestro # daemon-only host +//! cargo install maestro # default: cli + daemon + tui //! ``` -#[cfg(not(any( - feature = "cli", - feature = "daemon", - feature = "registry", - feature = "tui" -)))] -compile_error!("at least one of the `cli`, `daemon`, `registry`, `tui` features must be enabled"); +#[cfg(not(any(feature = "cli", feature = "daemon", feature = "tui")))] +compile_error!("at least one of the `cli`, `daemon`, `tui` features must be enabled"); use std::path::PathBuf; use std::process::ExitCode; @@ -25,20 +19,15 @@ use tracing_subscriber::EnvFilter; #[command( name = "maestro", version, - about = "Unified CLI / daemon / registry for the Maestro terminal-session orchestrator", + about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator", arg_required_else_help = true )] struct Cli { - /// Override the registry URL for this invocation (e.g. `https://maestro.example.com`). - /// Used by CLI subcommands; ignored by `daemon` / `registry`. - #[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")] - registry_url: Option, - - /// Override the home-daemon HTTP base the CLI brokers through (e.g. - /// `http://127.0.0.1:7070`). Defaults to `[cli].home_daemon_url`, then the - /// local `[daemon].bind_addr`, then `http://127.0.0.1:7070`. CLI only. - #[arg(long, global = true, env = "MAESTRO_HOME_DAEMON_URL")] - home_daemon_url: Option, + /// Override the daemon HTTP base clients talk to (e.g. + /// `http://127.0.0.1:7070`). Defaults to `[cli].daemon_url`, then the local + /// `[daemon].bind_addr`, then `http://127.0.0.1:7070`. + #[arg(long, global = true, env = "MAESTRO_DAEMON_URL")] + daemon_url: Option, /// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`. #[arg(long, global = true, env = "MAESTRO_CONFIG")] @@ -54,10 +43,10 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// List all sessions, grouped by daemon. + /// List all sessions. #[cfg(feature = "cli")] List(maestro_cli::cli::ListArgs), - /// Spawn a new session on a given daemon. + /// Spawn a new session. #[cfg(feature = "cli")] Spawn(maestro_cli::cli::SpawnArgs), /// Attach to a session over WebSocket. Bytes flow through the local TTY. @@ -66,21 +55,13 @@ enum Command { /// Kill a session by ID. #[cfg(feature = "cli")] Kill(maestro_cli::cli::KillArgs), - /// Set the default registry URL persisted in the CLI config. - #[cfg(feature = "cli")] - SetRegistry(maestro_cli::cli::SetRegistryArgs), - - /// Per-host daemon: tmux wrapper + registry client. + /// Per-host daemon: tmux wrapper + session API. #[cfg(feature = "daemon")] Daemon { #[command(subcommand)] cmd: Option, }, - /// Always-on registry: presence index + session catalog. - #[cfg(feature = "registry")] - Registry, - /// Open the terminal UI. #[cfg(feature = "tui")] Tui(maestro_tui::cli::TuiArgs), @@ -111,8 +92,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { cli_dispatch( maestro_cli::cli::Command::List(args), cli.config, - cli.registry_url, - cli.home_daemon_url, + cli.daemon_url.clone(), ) .await? } @@ -121,8 +101,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { cli_dispatch( maestro_cli::cli::Command::Spawn(args), cli.config, - cli.registry_url, - cli.home_daemon_url, + cli.daemon_url.clone(), ) .await? } @@ -131,8 +110,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { cli_dispatch( maestro_cli::cli::Command::Attach(args), cli.config, - cli.registry_url, - cli.home_daemon_url, + cli.daemon_url.clone(), ) .await? } @@ -141,29 +119,17 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { cli_dispatch( maestro_cli::cli::Command::Kill(args), cli.config, - cli.registry_url, - cli.home_daemon_url, - ) - .await? - } - #[cfg(feature = "cli")] - Command::SetRegistry(args) => { - cli_dispatch( - maestro_cli::cli::Command::SetRegistry(args), - cli.config, - cli.registry_url, - cli.home_daemon_url, + cli.daemon_url.clone(), ) .await? } #[cfg(feature = "daemon")] Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?, - #[cfg(feature = "registry")] - Command::Registry => maestro_registry::run(cli.config).await?, #[cfg(feature = "tui")] Command::Tui(args) => { let daemon_url = args .daemon_url + .or(cli.daemon_url) .unwrap_or_else(|| "http://127.0.0.1:7070".to_string()); maestro_tui::run(maestro_tui::RunConfig { daemon_url, @@ -179,10 +145,9 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { async fn cli_dispatch( command: maestro_cli::cli::Command, config: Option, - registry_url: Option, - home_daemon_url: Option, + daemon_url: Option, ) -> Result<(), maestro_cli::CliError> { - maestro_cli::dispatch(command, config, registry_url, home_daemon_url).await + maestro_cli::dispatch(command, config, daemon_url).await } fn init_tracing(cli: &Cli) { @@ -201,11 +166,7 @@ fn init_tracing(cli: &Cli) { fn is_cli_subcommand(command: &Command) -> bool { matches!( command, - Command::List(_) - | Command::Spawn(_) - | Command::Attach(_) - | Command::Kill(_) - | Command::SetRegistry(_) + Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_) ) }