diff --git a/CONFIGURATION.md b/CONFIGURATION.md index a494d0cc..06afdb48 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -6,6 +6,15 @@ herdr reads config from: ~/.config/herdr/config.toml ``` +Named sessions share this config file. Sessions are runtime/socket namespaces, not workspace replacements; per-session sockets and persistent runtime state are separate: + +```text +~/.config/herdr/session.json +~/.config/herdr/sessions//session.json +``` + +Use `herdr session list`, `herdr session stop `, and `herdr session delete ` to inspect and manage named session namespaces. + print the full default config with: ```bash diff --git a/README.md b/README.md index 0ff1ef93..8ecaea54 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,17 @@ herdr update herdr ``` -by default herdr launches or attaches to a background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the server. use `--no-session` for the old single-process mode. +by default herdr launches or attaches to one background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the default server. use `--no-session` for the old single-process mode. + +named sessions are runtime/socket namespaces for separate persistent herdr servers. they do not replace workspaces; each named session has its own panes, tabs, workspaces, sockets, and session state while sharing the same global config file. + +```bash +herdr --session work +herdr --session side-project +herdr session list +herdr session stop work +herdr session delete side-project +``` 1. press `n` to create a workspace 2. run an agent in the root pane diff --git a/SOCKET_API.md b/SOCKET_API.md index b9da2efb..ea834e14 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -23,13 +23,21 @@ important difference: `pane.run` and `wait agent-status` are **cli conveniences* - request/response: send one json request per line, read one json response per line - subscriptions: send `events.subscribe`, receive an ack, then keep the same connection open and continue reading pushed events +named sessions are runtime/socket namespaces, not replacements for herdr workspaces. each named session has its own server sockets and persistent runtime state while config remains global. + socket path resolution order: -1. `HERDR_SOCKET_PATH` -2. `$XDG_RUNTIME_DIR/herdr.sock` -3. `$XDG_CONFIG_HOME/herdr/herdr.sock` -4. `$HOME/.config/herdr/herdr.sock` -5. `/tmp/herdr.sock` +1. explicit `herdr --session `: + `$XDG_CONFIG_HOME/herdr/sessions//herdr.sock` or `$HOME/.config/herdr/sessions//herdr.sock` +2. `HERDR_SOCKET_PATH` +3. `HERDR_SESSION=`: + `$XDG_CONFIG_HOME/herdr/sessions//herdr.sock` or `$HOME/.config/herdr/sessions//herdr.sock` +4. default session path: + `$XDG_CONFIG_HOME/herdr/herdr.sock` or `$HOME/.config/herdr/herdr.sock` + +this means `HERDR_SOCKET_PATH` remains an exact low-level socket override, but an explicit cli `--session ` still wins when a command runs inside a pane that inherited `HERDR_SOCKET_PATH`. + +session names may contain ASCII letters, numbers, `.`, `_`, and `-`. `default` is reserved for the default session. use `herdr session list`, `herdr session stop `, and `herdr session delete ` to inspect and manage session namespaces. `session delete` refuses running sessions and does not delete the default session. ## request and response envelopes diff --git a/src/api/mod.rs b/src/api/mod.rs index e97d94f6..ff53e6ad 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -95,11 +95,7 @@ impl EventHub { } pub fn socket_path() -> PathBuf { - if let Ok(path) = std::env::var(SOCKET_PATH_ENV_VAR) { - return PathBuf::from(path); - } - - crate::config::config_dir().join("herdr.sock") + crate::session::active_api_socket_path() } pub struct ServerHandle { @@ -1064,6 +1060,8 @@ mod tests { fn socket_path_prefers_explicit_env_override() { let _guard = env_lock().lock().unwrap(); let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); std::env::set_var(SOCKET_PATH_ENV_VAR, &unique); assert_eq!(socket_path(), PathBuf::from(&unique)); std::env::remove_var(SOCKET_PATH_ENV_VAR); @@ -1075,6 +1073,8 @@ mod tests { let config_home = unique_test_path("socket-default-config-home"); let runtime_dir = unique_test_path("socket-default-runtime"); std::env::remove_var(SOCKET_PATH_ENV_VAR); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); std::env::set_var("XDG_CONFIG_HOME", &config_home); std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); @@ -1087,6 +1087,26 @@ mod tests { std::env::remove_var("XDG_RUNTIME_DIR"); } + #[test] + fn socket_path_uses_named_session_dir() { + let _guard = env_lock().lock().unwrap(); + let config_home = unique_test_path("socket-named-config-home"); + std::env::remove_var(SOCKET_PATH_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + + let expected = config_home + .join(crate::config::app_dir_name()) + .join("sessions") + .join("work") + .join("herdr.sock"); + assert_eq!(socket_path(), expected); + + std::env::remove_var(crate::session::SESSION_ENV_VAR); + std::env::remove_var("XDG_CONFIG_HOME"); + } + #[test] fn restrict_socket_permissions_sets_user_only_mode() { let dir = unique_test_path("socket-perms"); diff --git a/src/cli.rs b/src/cli.rs index 52fba0bd..138815fa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,6 +37,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { "pane" => run_pane_command(&args[2..])?, "wait" => run_wait_command(&args[2..])?, "integration" => run_integration_command(&args[2..])?, + "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; @@ -330,6 +331,27 @@ fn run_integration_command(args: &[String]) -> std::io::Result { } } +fn run_session_command(args: &[String]) -> std::io::Result { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_session_help(); + return Ok(2); + }; + + match subcommand { + "list" => session_list(&args[1..]), + "stop" => session_stop(&args[1..]), + "delete" => session_delete(&args[1..]), + "help" | "--help" | "-h" => { + print_session_help(); + Ok(0) + } + _ => { + print_session_help(); + Ok(2) + } + } +} + fn server_stop(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr server stop"); @@ -351,6 +373,76 @@ fn server_reload_config(args: &[String]) -> std::io::Result { })?) } +fn session_list(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr session list"); + return Ok(2); + } + + let sessions = crate::session::list_sessions()?; + _print_json(&serde_json::json!({ + "sessions": sessions, + })); + Ok(0) +} + +fn session_stop(args: &[String]) -> std::io::Result { + let Some(name) = args.first() else { + eprintln!("usage: herdr session stop "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr session stop "); + return Ok(2); + } + + let target = match crate::session::parse_target_name(name) { + Ok(target) => target, + Err(message) => { + print_session_error("invalid_session_name", &message); + return Ok(1); + } + }; + match crate::session::stop_session(target.as_deref()) { + Ok(session) => { + _print_json(&serde_json::json!({ + "stopped": true, + "session": session, + })); + Ok(0) + } + Err(message) => { + print_session_error("session_stop_failed", &message); + Ok(1) + } + } +} + +fn session_delete(args: &[String]) -> std::io::Result { + let Some(name) = args.first() else { + eprintln!("usage: herdr session delete "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr session delete "); + return Ok(2); + } + + match crate::session::delete_session(name) { + Ok(session) => { + _print_json(&serde_json::json!({ + "deleted": true, + "session": session, + })); + Ok(0) + } + Err(message) => { + print_session_error("session_delete_failed", &message); + Ok(1) + } + } +} + fn workspace_list(args: &[String]) -> std::io::Result { if !args.is_empty() { eprintln!("usage: herdr workspace list"); @@ -1225,6 +1317,19 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result { .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) } +fn print_session_error(code: &str, message: &str) { + eprintln!( + "{}", + serde_json::to_string(&serde_json::json!({ + "error": { + "code": code, + "message": message, + } + })) + .unwrap() + ); +} + fn print_server_help() { eprintln!("herdr server commands:"); eprintln!(" herdr server run as headless server"); @@ -1293,6 +1398,14 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall opencode"); } +fn print_session_help() { + eprintln!("herdr session commands:"); + eprintln!(" herdr session list"); + eprintln!(" herdr session stop "); + eprintln!(" herdr session delete "); + eprintln!(" use 'default' as to target the default session for stop"); +} + fn _print_json(value: &T) { println!("{}", serde_json::to_string(value).unwrap()); } diff --git a/src/logging.rs b/src/logging.rs index 2377091e..2754062b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -11,7 +11,7 @@ const DEFAULT_RETAINED_LOG_FILES: usize = 0; pub(crate) fn init_file_logging(file_name: &str) { let Ok(make_writer) = RotatingFileMakeWriter::new( - crate::config::config_dir(), + crate::session::data_dir(), file_name, DEFAULT_MAX_LOG_BYTES, DEFAULT_RETAINED_LOG_FILES, @@ -31,7 +31,7 @@ pub(crate) fn init_file_logging(file_name: &str) { } pub(crate) fn help_log_paths_summary() -> String { - let dir = crate::config::config_dir(); + let dir = crate::session::data_dir(); format!( "{} (plus herdr-client.log, herdr-server.log)", dir.join("herdr.log").display() diff --git a/src/main.rs b/src/main.rs index dbc77060..cf5842c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ mod raw_input; mod release_notes; mod selection; mod server; +mod session; mod sound; mod terminal_notify; mod terminal_theme; @@ -160,7 +161,15 @@ fn random_nested_message() -> &'static str { } fn main() -> io::Result<()> { - let args: Vec = std::env::args().collect(); + let raw_args: Vec = std::env::args().collect(); + let args = match session::configure_from_args(&raw_args) { + Ok(args) => args, + Err(err) => { + eprintln!("error: {err}"); + eprintln!("run 'herdr --help' for usage"); + std::process::exit(2); + } + }; if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? { std::process::exit(code); @@ -189,7 +198,17 @@ fn main() -> io::Result<()> { if args.iter().any(|a| a == "--help" || a == "-h") { println!("herdr — terminal workspace manager for AI coding agents"); println!(); - println!("Usage: herdr [options] [command]"); + println!("Usage: herdr [options]"); + println!(" herdr --session [options]"); + println!(" herdr update"); + println!(" herdr server stop"); + println!(" herdr server reload-config"); + println!(" herdr workspace ..."); + println!(" herdr tab ..."); + println!(" herdr pane ..."); + println!(" herdr wait ..."); + println!(" herdr session ..."); + println!(" herdr integration ..."); println!(); println!("Common commands:"); println!( @@ -228,6 +247,10 @@ fn main() -> io::Result<()> { " {:<32} {}", "herdr wait ", "Blocking wait helpers over the socket API" ); + println!( + " {:<32} {}", + "herdr session ", "Manage named persistent sessions" + ); println!( " {:<32} {}", "herdr integration ", "Manage built-in agent integrations" @@ -242,6 +265,7 @@ fn main() -> io::Result<()> { println!(); println!("Options:"); println!(" --no-session Run monolithically (no server/client, escape hatch)"); + println!(" --session Use or create a named persistent session"); println!(" --default-config Print default configuration and exit"); println!(" --version, -V Print version and exit"); println!(" --help, -h Show this help"); @@ -266,6 +290,7 @@ fn main() -> io::Result<()> { // Reject unknown flags let known_flags = [ "--no-session", + "--session", "--version", "-V", "--default-config", @@ -287,6 +312,7 @@ fn main() -> io::Result<()> { "workspace", "pane", "wait", + "session", "integration", ] .contains(&arg.as_str()) diff --git a/src/persist/io.rs b/src/persist/io.rs index f4acd662..8cd25dfb 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -5,7 +5,7 @@ use tracing::warn; use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION}; fn session_path() -> PathBuf { - crate::config::config_dir().join("session.json") + crate::session::data_dir().join("session.json") } pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> { diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index c4a07a2d..8f695467 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -12,6 +12,7 @@ use std::io; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::time::Duration; @@ -85,8 +86,9 @@ fn is_server_listening_at(socket_path: &Path) -> bool { /// The server process is fully detached: /// - Runs in its own session (setsid) so it survives the client exiting /// - Stdin/stdout/stderr are redirected to /dev/null -/// - Inherits all relevant environment variables -/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.) +/// - Inherits relevant environment variables (`XDG_CONFIG_HOME`, `HERDR_SESSION`, +/// socket overrides, etc.), except inherited socket overrides are cleared when +/// this CLI invocation explicitly selected a session. /// /// Returns the PID of the spawned server process. pub fn spawn_server_daemon() -> io::Result { @@ -99,7 +101,21 @@ pub fn spawn_server_daemon() -> io::Result { info!(exe = %exe.display(), "spawning server daemon"); - let child = Command::new(&exe) + let mut command = build_server_daemon_command(exe); + + let child = command.spawn().map_err(|err: io::Error| { + io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) + })?; + + let pid = child.id(); + info!(pid, "server daemon spawned"); + + Ok(pid) +} + +fn build_server_daemon_command(exe: PathBuf) -> Command { + let mut command = Command::new(&exe); + command .arg("server") // Create a new process group so the server survives the parent's exit // and doesn't receive SIGHUP when the client's terminal closes. @@ -107,16 +123,15 @@ pub fn spawn_server_daemon() -> io::Result { // Redirect stdio to /dev/null .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|err: io::Error| { - io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) - })?; + .stderr(std::process::Stdio::null()); - let pid = child.id(); - info!(pid, "server daemon spawned"); + if crate::session::explicit_session_requested() { + command + .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + .env_remove("HERDR_CLIENT_SOCKET_PATH"); + } - Ok(pid) + command } // --------------------------------------------------------------------------- @@ -187,7 +202,14 @@ pub fn auto_detect_launch() -> io::Result<()> { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsStr; use std::os::unix::net::UnixListener; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } fn unique_test_dir(name: &str) -> std::path::PathBuf { let nanos = std::time::SystemTime::now() @@ -204,6 +226,35 @@ mod tests { assert!(!is_server_listening_at(&path)); } + #[test] + fn server_daemon_command_clears_socket_overrides_for_explicit_session() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock"); + std::env::set_var("HERDR_CLIENT_SOCKET_PATH", "/tmp/inherited-client.sock"); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + let args = vec![ + "herdr".to_string(), + "--session".to_string(), + "work".to_string(), + ]; + crate::session::configure_from_args(&args).unwrap(); + + let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test")); + let envs: Vec<_> = command.get_envs().collect(); + + assert!(envs.iter().any(|(key, value)| { + *key == OsStr::new(crate::api::SOCKET_PATH_ENV_VAR) && value.is_none() + })); + assert!(envs.iter().any(|(key, value)| { + *key == OsStr::new("HERDR_CLIENT_SOCKET_PATH") && value.is_none() + })); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + std::env::remove_var("HERDR_CLIENT_SOCKET_PATH"); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); + } + #[test] fn is_server_listening_returns_true_for_live_socket() { let dir = unique_test_dir("live"); diff --git a/src/server/headless.rs b/src/server/headless.rs index 5f9aad2c..62cf3ba1 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -152,12 +152,16 @@ fn toast_message_from_state_change( /// Returns the path for the client protocol socket. /// /// Contract-aligned override behavior: -/// 1. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by +/// 1. If CLI `--session ` is active, use that session's client socket. +/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by /// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`). /// This keeps JSON API and client socket overrides consistent. -/// 2. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). -/// 3. Otherwise, use the app config directory. +/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback). +/// 4. Otherwise, use the active session data directory. pub fn client_socket_path() -> PathBuf { + if crate::session::explicit_session_requested() { + return crate::session::client_socket_path_for(crate::session::active_name().as_deref()); + } client_socket_path_from_overrides( std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(), std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(), @@ -176,7 +180,7 @@ fn client_socket_path_from_overrides( return PathBuf::from(client_socket_override); } - config::config_dir().join("herdr-client.sock") + crate::session::client_socket_path_for(crate::session::active_name().as_deref()) } fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { @@ -2178,6 +2182,8 @@ mod tests { #[test] fn client_socket_path_defaults_to_config_dir() { + std::env::remove_var(crate::session::SESSION_ENV_VAR); + crate::session::clear_explicit_session_for_test(); let path = client_socket_path_from_overrides(None, None); assert_eq!(path, config::config_dir().join("herdr-client.sock")); } diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 00000000..feeb9740 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,569 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +pub const SESSION_ENV_VAR: &str = "HERDR_SESSION"; +pub const DEFAULT_SESSION_NAME: &str = "default"; + +const MAX_SESSION_NAME_LEN: usize = 64; +const STOP_WAIT_TIMEOUT: Duration = Duration::from_secs(2); +const STOP_WAIT_POLL: Duration = Duration::from_millis(25); + +static EXPLICIT_SESSION_REQUESTED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct SessionInfo { + pub name: String, + pub default: bool, + pub running: bool, + pub socket_path: String, + pub session_dir: String, +} + +pub fn configure_from_args(args: &[String]) -> Result, String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut requested_session = None; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--session" { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --session".to_string()); + }; + requested_session = Some(value.clone()); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--session=") { + requested_session = Some(value.to_string()); + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + if let Some(session) = requested_session { + let session = normalize_name(&session)?; + if let Some(session) = session { + std::env::set_var(SESSION_ENV_VAR, session); + } else { + std::env::remove_var(SESSION_ENV_VAR); + } + EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + } else if std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() { + EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed); + } else if let Ok(session) = std::env::var(SESSION_ENV_VAR) { + if normalize_name(&session)?.is_none() { + std::env::remove_var(SESSION_ENV_VAR); + } + EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed); + } else { + EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed); + } + + Ok(cleaned) +} + +pub fn active_name() -> Option { + std::env::var(SESSION_ENV_VAR) + .ok() + .filter(|name| name != DEFAULT_SESSION_NAME) + .filter(|name| validate_name(name).is_ok()) +} + +pub fn explicit_session_requested() -> bool { + EXPLICIT_SESSION_REQUESTED.load(Ordering::Relaxed) +} + +#[cfg(test)] +pub(crate) fn clear_explicit_session_for_test() { + EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed); +} + +pub fn data_dir() -> PathBuf { + data_dir_for(active_name().as_deref()) +} + +pub fn data_dir_for(name: Option<&str>) -> PathBuf { + let config_dir = crate::config::config_dir(); + match name { + Some(name) => config_dir.join("sessions").join(name), + None => config_dir, + } +} + +pub fn api_socket_path_for(name: Option<&str>) -> PathBuf { + data_dir_for(name).join("herdr.sock") +} + +pub fn active_api_socket_path() -> PathBuf { + if explicit_session_requested() { + return api_socket_path_for(active_name().as_deref()); + } + if let Ok(path) = std::env::var(crate::api::SOCKET_PATH_ENV_VAR) { + return PathBuf::from(path); + } + api_socket_path_for(active_name().as_deref()) +} + +pub fn client_socket_path_for(name: Option<&str>) -> PathBuf { + data_dir_for(name).join("herdr-client.sock") +} + +pub fn list_sessions() -> std::io::Result> { + let mut sessions = vec![session_info(None)]; + let sessions_dir = crate::config::config_dir().join("sessions"); + let entries = match std::fs::read_dir(&sessions_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions), + Err(err) => return Err(err), + }; + + let mut names = Vec::new(); + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + if name != DEFAULT_SESSION_NAME && validate_name(&name).is_ok() { + names.push(name); + } + } + names.sort(); + sessions.extend(names.iter().map(|name| session_info(Some(name)))); + Ok(sessions) +} + +pub fn session_info(name: Option<&str>) -> SessionInfo { + let default = name.is_none(); + let display_name = name.unwrap_or(DEFAULT_SESSION_NAME).to_string(); + let socket_path = api_socket_path_for(name); + let session_dir = data_dir_for(name); + SessionInfo { + name: display_name, + default, + running: is_running_at(&socket_path), + socket_path: socket_path.display().to_string(), + session_dir: session_dir.display().to_string(), + } +} + +pub fn parse_target_name(name: &str) -> Result, String> { + normalize_name(name) +} + +pub fn stop_session(name: Option<&str>) -> Result { + stop_session_with_timeout(name, STOP_WAIT_TIMEOUT) +} + +fn stop_session_with_timeout(name: Option<&str>, timeout: Duration) -> Result { + let socket_path = api_socket_path_for(name); + let request = serde_json::json!({ + "id": "cli:session:stop", + "method": "server.stop", + "params": {} + }); + let mut stream = UnixStream::connect(&socket_path).map_err(|err| { + format!( + "session {} is not running or cannot be reached at {}: {err}", + name.unwrap_or(DEFAULT_SESSION_NAME), + socket_path.display() + ) + })?; + stream + .write_all(request.to_string().as_bytes()) + .map_err(|err| err.to_string())?; + stream.write_all(b"\n").map_err(|err| err.to_string())?; + stream.flush().map_err(|err| err.to_string())?; + + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .map_err(|err| err.to_string())?; + let response: serde_json::Value = serde_json::from_str(&line).map_err(|err| err.to_string())?; + if let Some(error) = response.get("error") { + return Err(error.to_string()); + } + if !wait_until_stopped(&socket_path, timeout) { + return Err(format!( + "session {} did not stop within {}ms; socket is still reachable at {}", + name.unwrap_or(DEFAULT_SESSION_NAME), + timeout.as_millis(), + socket_path.display() + )); + } + Ok(session_info(name)) +} + +pub fn delete_session(name: &str) -> Result { + if name == DEFAULT_SESSION_NAME { + return Err("deleting the default session is not supported".to_string()); + } + validate_name(name)?; + let socket_path = api_socket_path_for(Some(name)); + if is_running_at(&socket_path) { + return Err(format!( + "session {name} is running; stop it before deleting" + )); + } + let info = session_info(Some(name)); + let dir = data_dir_for(Some(name)); + match std::fs::remove_dir_all(&dir) { + Ok(()) => Ok(info), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(info), + Err(err) => Err(err.to_string()), + } +} + +fn is_running_at(socket_path: &Path) -> bool { + socket_path.exists() && UnixStream::connect(socket_path).is_ok() +} + +fn wait_until_stopped(socket_path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !is_running_at(socket_path) { + return true; + } + std::thread::sleep(STOP_WAIT_POLL); + } + !is_running_at(socket_path) +} + +pub fn validate_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("session name cannot be empty".to_string()); + } + if name.len() > MAX_SESSION_NAME_LEN { + return Err(format!( + "session name cannot be longer than {MAX_SESSION_NAME_LEN} bytes" + )); + } + if name == "." || name == ".." { + return Err("session name cannot be . or ..".to_string()); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err( + "session name may only contain ASCII letters, numbers, '.', '_' and '-'".to_string(), + ); + } + Ok(()) +} + +fn normalize_name(name: &str) -> Result, String> { + if name == DEFAULT_SESSION_NAME { + return Ok(None); + } + validate_name(name)?; + Ok(Some(name.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn configure_from_args_removes_global_session_option() { + let _guard = env_lock().lock().unwrap(); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + let args = vec![ + "herdr".to_string(), + "--session".to_string(), + "work".to_string(), + "workspace".to_string(), + "list".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("work")); + assert!(explicit_session_requested()); + assert_eq!(cleaned, vec!["herdr", "workspace", "list"]); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + } + + #[test] + fn configure_from_args_accepts_equals_form() { + let _guard = env_lock().lock().unwrap(); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + let args = vec![ + "herdr".to_string(), + "server".to_string(), + "stop".to_string(), + "--session=api".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("api")); + assert!(explicit_session_requested()); + assert_eq!(cleaned, vec!["herdr", "server", "stop"]); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + } + + #[test] + fn configure_from_args_maps_default_session_name_to_default_path() { + let _guard = env_lock().lock().unwrap(); + let config_home = + std::env::temp_dir().join(format!("herdr-session-default-{}", std::process::id())); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::set_var(SESSION_ENV_VAR, "work"); + clear_explicit_session_for_test(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock"); + let args = vec![ + "herdr".to_string(), + "--session".to_string(), + DEFAULT_SESSION_NAME.to_string(), + "workspace".to_string(), + "list".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr", "workspace", "list"]); + assert!(std::env::var(SESSION_ENV_VAR).is_err()); + assert!(explicit_session_requested()); + assert_eq!( + active_api_socket_path(), + config_home + .join(crate::config::app_dir_name()) + .join("herdr.sock") + ); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn env_session_does_not_mark_session_explicit() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var(SESSION_ENV_VAR, "env-session"); + EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + let args = vec![ + "herdr".to_string(), + "workspace".to_string(), + "list".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr", "workspace", "list"]); + assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("env-session")); + assert!(!explicit_session_requested()); + std::env::remove_var(SESSION_ENV_VAR); + } + + #[test] + fn env_default_session_name_uses_default_path() { + let _guard = env_lock().lock().unwrap(); + let config_home = + std::env::temp_dir().join(format!("herdr-env-session-default-{}", std::process::id())); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + std::env::set_var(SESSION_ENV_VAR, DEFAULT_SESSION_NAME); + EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + let args = vec![ + "herdr".to_string(), + "workspace".to_string(), + "list".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr", "workspace", "list"]); + assert!(std::env::var(SESSION_ENV_VAR).is_err()); + assert!(!explicit_session_requested()); + assert_eq!( + active_api_socket_path(), + config_home + .join(crate::config::app_dir_name()) + .join("herdr.sock") + ); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::remove_var(SESSION_ENV_VAR); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + clear_explicit_session_for_test(); + } + + #[test] + fn explicit_session_socket_ignores_inherited_socket_override() { + let _guard = env_lock().lock().unwrap(); + let config_home = + std::env::temp_dir().join(format!("herdr-session-precedence-{}", std::process::id())); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::set_var(SESSION_ENV_VAR, "work"); + EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock"); + + let path = active_api_socket_path(); + + assert_eq!( + path, + config_home + .join(crate::config::app_dir_name()) + .join("sessions") + .join("work") + .join("herdr.sock") + ); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn env_socket_override_wins_without_explicit_session() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var(SESSION_ENV_VAR, "work"); + clear_explicit_session_for_test(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/explicit.sock"); + + assert_eq!( + active_api_socket_path(), + PathBuf::from("/tmp/explicit.sock") + ); + + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn env_socket_override_skips_invalid_env_session_validation_without_explicit_session() { + let _guard = env_lock().lock().unwrap(); + std::env::set_var(SESSION_ENV_VAR, "bad/name"); + clear_explicit_session_for_test(); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/herdr.sock"); + let args = vec![ + "herdr".to_string(), + "workspace".to_string(), + "list".to_string(), + ]; + + let cleaned = configure_from_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr", "workspace", "list"]); + assert!(!explicit_session_requested()); + assert_eq!(active_api_socket_path(), PathBuf::from("/tmp/herdr.sock")); + assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("bad/name")); + + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + } + + #[test] + fn stop_session_fails_when_socket_remains_reachable_after_timeout() { + let _guard = env_lock().lock().unwrap(); + let config_home = PathBuf::from(format!("/tmp/hs-stop-{}", std::process::id())); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + let session_name = "slow"; + let socket_path = api_socket_path_for(Some(session_name)); + std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap(); + let _ = std::fs::remove_file(&socket_path); + let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let keep_running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let keep_running_for_thread = keep_running.clone(); + let handle = std::thread::spawn(move || { + while keep_running_for_thread.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + if let Ok(reader_stream) = stream.try_clone() { + let mut request = String::new(); + let _ = BufReader::new(reader_stream).read_line(&mut request); + } + let _ = stream.write_all(b"{\"id\":\"cli:session:stop\",\"result\":{}}\n"); + let _ = stream.flush(); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + + let err = stop_session_with_timeout(Some(session_name), Duration::from_millis(75)) + .expect_err("still-running session should fail"); + + assert!(err.contains("did not stop"), "{err}"); + assert!( + err.contains(socket_path.to_string_lossy().as_ref()), + "{err}" + ); + keep_running.store(false, Ordering::Relaxed); + handle.join().unwrap(); + let _ = std::fs::remove_dir_all(&config_home); + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn invalid_names_are_rejected() { + let _guard = env_lock().lock().unwrap(); + assert!(validate_name("../prod").is_err()); + assert!(validate_name("").is_err()); + assert!(validate_name("work session").is_err()); + } + + #[test] + fn parse_default_target_name_maps_to_default_session() { + assert_eq!(parse_target_name(DEFAULT_SESSION_NAME).unwrap(), None); + assert_eq!(parse_target_name("work").unwrap(), Some("work".to_string())); + } + + #[test] + fn delete_default_session_is_rejected() { + assert!(delete_session(DEFAULT_SESSION_NAME).is_err()); + } + + #[test] + fn list_sessions_skips_reserved_default_directory() { + let _guard = env_lock().lock().unwrap(); + let config_home = + std::env::temp_dir().join(format!("herdr-session-list-{}", std::process::id())); + let sessions_dir = config_home + .join(crate::config::app_dir_name()) + .join("sessions"); + std::fs::create_dir_all(sessions_dir.join(DEFAULT_SESSION_NAME)).unwrap(); + std::fs::create_dir_all(sessions_dir.join("work")).unwrap(); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(SESSION_ENV_VAR); + clear_explicit_session_for_test(); + + let sessions = list_sessions().unwrap(); + let names: Vec<_> = sessions + .iter() + .map(|session| session.name.as_str()) + .collect(); + + assert_eq!(names, vec![DEFAULT_SESSION_NAME, "work"]); + std::fs::remove_dir_all(&config_home).unwrap(); + std::env::remove_var("XDG_CONFIG_HOME"); + } +} diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index caad5b39..03bfaad4 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -29,6 +29,19 @@ struct SpawnedHerdr { child: Box, } +struct SpawnedServerProcess { + child: std::process::Child, +} + +impl Drop for SpawnedServerProcess { + fn drop(&mut self) { + let pid = self.child.id(); + let _ = self.child.kill(); + let _ = self.child.wait(); + unregister_spawned_herdr_pid(Some(pid)); + } +} + impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); @@ -71,6 +84,91 @@ fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> Sp spawn_herdr_with_path(config_home, runtime_dir, socket_path, None) } +fn app_dir_name() -> &'static str { + if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + } +} + +fn named_session_socket(config_home: &Path, session: &str) -> PathBuf { + config_home + .join(app_dir_name()) + .join("sessions") + .join(session) + .join("herdr.sock") +} + +fn spawn_named_server( + config_home: &Path, + runtime_dir: &Path, + session: &str, +) -> SpawnedServerProcess { + fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); + fs::create_dir_all(runtime_dir).unwrap(); + register_runtime_dir(runtime_dir); + fs::write( + config_home.join(app_dir_name()).join("config.toml"), + "onboarding = false\n", + ) + .unwrap(); + + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command + .args(["--session", session, "server"]) + .env("XDG_CONFIG_HOME", config_home) + .env("XDG_RUNTIME_DIR", runtime_dir) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let child = command.spawn().unwrap(); + register_spawned_herdr_pid(Some(child.id())); + SpawnedServerProcess { child } +} + +fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output { + run_named_cli_with_socket_override(config_home, runtime_dir, args, None) +} + +fn run_named_cli_with_socket_override( + config_home: &Path, + runtime_dir: &Path, + args: &[&str], + socket_override: Option<&Path>, +) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command + .args(args) + .env("XDG_CONFIG_HOME", config_home) + .env("XDG_RUNTIME_DIR", runtime_dir) + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV"); + if let Some(socket_override) = socket_override { + command.env("HERDR_SOCKET_PATH", socket_override); + } else { + command.env_remove("HERDR_SOCKET_PATH"); + } + command.output().unwrap() +} + +fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value { + let output = run_named_cli(config_home, runtime_dir, args); + assert!( + output.status.success(), + "command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}", + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + fn spawn_herdr_with_path( config_home: &Path, runtime_dir: &Path, @@ -472,6 +570,7 @@ fn help_commands_exit_successfully() { &["tab", "-h"], &["pane", "-h"], &["wait", "-h"], + &["session", "-h"], &["integration", "-h"], ]; @@ -511,6 +610,166 @@ fn removed_show_changelog_flag_fails_before_nested_guard() { ); } +#[test] +fn named_sessions_use_separate_servers_and_workspace_state() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + + let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); + let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); + + wait_for_socket( + &named_session_socket(&config_home, "alpha"), + Duration::from_secs(5), + ); + wait_for_socket( + &named_session_socket(&config_home, "beta"), + Duration::from_secs(5), + ); + + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "alpha", + "workspace", + "create", + "--label", + "alpha-ws", + "--no-focus", + ], + ); + run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "beta", + "workspace", + "create", + "--label", + "beta-ws", + "--no-focus", + ], + ); + + let alpha_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "alpha", "workspace", "list"], + ); + let beta_list = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "workspace", "list"], + ); + + let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + let beta_labels: Vec<_> = beta_list["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + + assert_eq!(alpha_labels, vec!["alpha-ws"]); + assert_eq!(beta_labels, vec!["beta-ws"]); + + let beta_via_explicit_session = run_named_cli_with_socket_override( + &config_home, + &runtime_dir, + &["--session", "beta", "workspace", "list"], + Some(&named_session_socket(&config_home, "alpha")), + ); + assert!( + beta_via_explicit_session.status.success(), + "stderr: {}", + String::from_utf8_lossy(&beta_via_explicit_session.stderr) + ); + let beta_via_explicit_session: serde_json::Value = + serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap(); + let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|workspace| workspace["label"].as_str().unwrap()) + .collect(); + assert_eq!(labels_via_explicit, vec!["beta-ws"]); + + let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list"]); + let sessions = sessions["sessions"].as_array().unwrap(); + let default_session = sessions + .iter() + .find(|session| session["name"] == "default") + .unwrap(); + let alpha_session = sessions + .iter() + .find(|session| session["name"] == "alpha") + .unwrap(); + let beta_session = sessions + .iter() + .find(|session| session["name"] == "beta") + .unwrap(); + assert_eq!(default_session["default"], true); + assert_eq!(default_session["running"], false); + assert_eq!(alpha_session["running"], true); + assert_eq!(beta_session["running"], true); + assert!(alpha_session["socket_path"] + .as_str() + .unwrap() + .ends_with("/sessions/alpha/herdr.sock")); + assert!(beta_session["session_dir"] + .as_str() + .unwrap() + .ends_with("/sessions/beta")); + + let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]); + assert_eq!(delete_running.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"), + "stderr: {}", + String::from_utf8_lossy(&delete_running.stderr) + ); + + let delete_default = run_named_cli( + &config_home, + &runtime_dir, + &["session", "delete", "default"], + ); + assert_eq!(delete_default.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&delete_default.stderr).contains("default session"), + "stderr: {}", + String::from_utf8_lossy(&delete_default.stderr) + ); + + let stopped_alpha = + run_named_cli_json(&config_home, &runtime_dir, &["session", "stop", "alpha"]); + assert_eq!(stopped_alpha["stopped"], true); + assert_eq!(stopped_alpha["session"]["running"], false); + + let deleted_alpha = + run_named_cli_json(&config_home, &runtime_dir, &["session", "delete", "alpha"]); + assert_eq!(deleted_alpha["deleted"], true); + assert!(!config_home + .join(app_dir_name()) + .join("sessions") + .join("alpha") + .exists()); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]); + drop(alpha); + drop(beta); + cleanup_test_base(&base); +} + #[test] fn integration_commands_honor_socket_override_when_server_is_missing() { let base = unique_test_dir();