Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
466 lines
16 KiB
Rust
466 lines
16 KiB
Rust
//! Daemon view over the unified Maestro config.
|
|
//!
|
|
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
|
|
//! `[spawn]` from `~/.config/maestro/config.toml`.
|
|
//!
|
|
//! Precedence per field: **env > file > default**. Every field has a default,
|
|
//! so a missing config file is fine — the daemon starts with defaults.
|
|
//!
|
|
//! Default config path: `~/.config/maestro/config.toml` (override via
|
|
//! `$MAESTRO_CONFIG`).
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use maestro_config::MaestroConfigFile;
|
|
|
|
pub use maestro_config::default_config_path;
|
|
|
|
/// Default bind address when neither env nor file specify one.
|
|
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
|
|
|
/// Default recency cap (days) for the durable spawn history (#24) when
|
|
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
|
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
|
|
|
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
|
|
/// is unset: the whole home directory, with the scanner's own ignore list
|
|
/// keeping it cheap.
|
|
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
|
|
/// Default directory depth for project discovery (#54) when
|
|
/// `[spawn].project_scan_depth` is unset.
|
|
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
|
|
|
|
/// Env var that overrides `[daemon].bind_addr`.
|
|
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
|
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
|
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
|
|
|
|
fn default_bind_addr() -> SocketAddr {
|
|
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ConfigError {
|
|
#[error(transparent)]
|
|
Inner(#[from] maestro_config::ConfigError),
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("invalid [spawn].presets: {0}")]
|
|
SpawnPresets(#[from] maestro_config::SpawnConfigError),
|
|
}
|
|
|
|
/// Resolved, validated daemon configuration ready to drive the binary.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DaemonConfig {
|
|
/// Hostname advertised to clients, for display only.
|
|
pub hostname: String,
|
|
/// Home directory of the OS user the daemon runs as, advertised to clients
|
|
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
|
|
/// can't be resolved (NAP-328).
|
|
pub home_dir: String,
|
|
pub bind_addr: SocketAddr,
|
|
pub state_dir: PathBuf,
|
|
/// `[daemon].worktree_root` override (#56). `None` — the default — places
|
|
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
|
|
/// operators who want them elsewhere set this.
|
|
pub worktree_root: Option<PathBuf>,
|
|
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
|
/// receives this much of the prior PTY output before the live stream.
|
|
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
|
pub pty_replay_bytes: usize,
|
|
/// `TMUX_TMPDIR` exported for every `tmux` invocation (NAP-234).
|
|
/// `None` lets tmux pick its own default (`/tmp/tmux-$UID`); `Some(p)`
|
|
/// forces sockets under `p` so bare `tmux ls` in a matching user shell
|
|
/// sees maestro sessions.
|
|
pub tmux_tmpdir: Option<PathBuf>,
|
|
/// Recency cap (days) for the durable spawn history that backs the spawn
|
|
/// form's "recent dirs" picker (#24). `0` disables history. Default
|
|
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
|
pub spawn_history_days: u32,
|
|
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
|
|
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
|
/// clients in `GET /api/info`.
|
|
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
|
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
|
|
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
|
|
pub project_roots: Vec<PathBuf>,
|
|
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
|
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
|
pub project_scan_depth: u32,
|
|
/// Whether the daemon installs the Claude Code activity hook on first
|
|
/// start when none is present (#106): `[hooks].auto_install`, default
|
|
/// `true`.
|
|
pub hooks_auto_install: bool,
|
|
}
|
|
|
|
impl DaemonConfig {
|
|
pub fn sidecar_dir(&self) -> PathBuf {
|
|
self.state_dir.join("sessions")
|
|
}
|
|
|
|
pub fn fifo_dir(&self) -> PathBuf {
|
|
self.state_dir.join("fifos")
|
|
}
|
|
|
|
/// JSONL spawn-history log path (#24): `<state_dir>/spawn-history.jsonl`.
|
|
pub fn spawn_history_path(&self) -> PathBuf {
|
|
self.state_dir.join("spawn-history.jsonl")
|
|
}
|
|
}
|
|
|
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
|
/// `Option`s; production reads from real env vars in `main`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct EnvOverrides {
|
|
pub bind_addr: Option<String>,
|
|
pub tmux_tmpdir: Option<String>,
|
|
}
|
|
|
|
impl EnvOverrides {
|
|
/// Read every supported env var from the process environment.
|
|
pub fn from_env() -> Self {
|
|
Self {
|
|
bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(),
|
|
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve the runtime configuration: env overrides on top of the unified
|
|
/// file, then defaults. A missing file is not an error.
|
|
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
|
|
let file = MaestroConfigFile::load(path)?;
|
|
|
|
let daemon = file.daemon;
|
|
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
|
// empty command) refuses startup with a clear error, like other config
|
|
// validation (#22/#26).
|
|
let spawn_presets = file.spawn.resolve()?;
|
|
|
|
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
|
|
let home_dir = dirs::home_dir()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
let bind_addr = match env.bind_addr {
|
|
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
|
|
None => daemon.bind_addr.unwrap_or_else(default_bind_addr),
|
|
};
|
|
|
|
let state_dir = match daemon.state_dir {
|
|
Some(p) => p,
|
|
None => default_state_dir()?,
|
|
};
|
|
|
|
let pty_replay_bytes = daemon
|
|
.pty_replay_bytes
|
|
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
|
|
let tmux_tmpdir = env
|
|
.tmux_tmpdir
|
|
.map(PathBuf::from)
|
|
.or(daemon.tmux_tmpdir)
|
|
.or_else(dirs::runtime_dir);
|
|
let spawn_history_days = daemon
|
|
.spawn_history_days
|
|
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
|
|
|
// `~` in scan roots means the daemon's own home; an unresolvable home
|
|
// leaves the tilde as-is and the scanner treats the root as missing.
|
|
let project_roots: Vec<PathBuf> = file
|
|
.spawn
|
|
.project_roots
|
|
.unwrap_or_else(|| {
|
|
DEFAULT_PROJECT_ROOTS
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
})
|
|
.iter()
|
|
.map(|root| expand_tilde(root, &home_dir))
|
|
.collect();
|
|
let project_scan_depth = file
|
|
.spawn
|
|
.project_scan_depth
|
|
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
|
|
|
|
Ok(DaemonConfig {
|
|
hostname,
|
|
home_dir,
|
|
bind_addr,
|
|
state_dir,
|
|
worktree_root: daemon.worktree_root,
|
|
pty_replay_bytes,
|
|
tmux_tmpdir,
|
|
spawn_history_days,
|
|
spawn_presets,
|
|
project_roots,
|
|
project_scan_depth,
|
|
hooks_auto_install: file.hooks.auto_install(),
|
|
})
|
|
}
|
|
|
|
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
|
|
fn expand_tilde(path: &str, home: &str) -> PathBuf {
|
|
match path.strip_prefix("~") {
|
|
Some(_) if home.is_empty() => PathBuf::from(path),
|
|
Some(rest) => {
|
|
let mut p = PathBuf::from(home);
|
|
p.push(rest.trim_start_matches('/'));
|
|
p
|
|
}
|
|
None => PathBuf::from(path),
|
|
}
|
|
}
|
|
|
|
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
|
Ok(maestro_config::default_state_dir("maestro")?)
|
|
}
|
|
|
|
/// Read the kernel hostname via `gethostname(2)`. Falls back to `"unknown"`
|
|
/// on the (very unlikely) syscall failure.
|
|
pub fn system_hostname() -> String {
|
|
let mut buf = [0u8; 256];
|
|
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
|
|
if r != 0 {
|
|
return "unknown".into();
|
|
}
|
|
let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
|
String::from_utf8_lossy(&buf[..nul]).into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert!(cfg.hooks_auto_install);
|
|
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[hooks]
|
|
auto_install = false
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert!(!cfg.hooks_auto_install);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_file_defaults_everything() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
|
assert!(cfg.state_dir.ends_with("maestro"));
|
|
assert!(cfg.worktree_root.is_none());
|
|
assert!(!cfg.hostname.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_file_is_not_an_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("does-not-exist.toml");
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
|
assert!(!path.exists(), "load_config must not create the file");
|
|
}
|
|
|
|
#[test]
|
|
fn env_overrides_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
bind_addr = "127.0.0.1:1"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let env = EnvOverrides {
|
|
bind_addr: Some("0.0.0.0:9999".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let cfg = load_config(&path, env).unwrap();
|
|
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_env_bind_addr_returns_diagnostic() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let env = EnvOverrides {
|
|
bind_addr: Some("not-an-addr".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let err = load_config(&path, env).unwrap_err();
|
|
match err {
|
|
ConfigError::Inner(maestro_config::ConfigError::InvalidBindAddr {
|
|
input, env, ..
|
|
}) => {
|
|
assert_eq!(input, "not-an-addr");
|
|
assert_eq!(env, ENV_DAEMON_BIND_ADDR);
|
|
}
|
|
other => panic!("expected InvalidBindAddr, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostname_from_file_wins_over_gethostname() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
hostname = "vm-42"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.hostname, "vm-42");
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_history_days_defaults_and_overrides() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
|
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
|
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
spawn_history_days = 0
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.spawn_history_days, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn project_scan_defaults_to_home_root_at_depth_3() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
|
|
assert_eq!(
|
|
cfg.project_roots,
|
|
vec![PathBuf::from(cfg.home_dir.clone())],
|
|
"bare ~ must expand to the daemon's home"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[spawn]
|
|
project_roots = ["~/code", "/srv/work"]
|
|
project_scan_depth = 5
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.project_scan_depth, 5);
|
|
assert_eq!(
|
|
cfg.project_roots,
|
|
vec![
|
|
PathBuf::from(cfg.home_dir.clone()).join("code"),
|
|
PathBuf::from("/srv/work"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tilde_expansion_handles_bare_and_nested_forms() {
|
|
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
|
|
assert_eq!(
|
|
expand_tilde("~/a/b", "/home/u"),
|
|
PathBuf::from("/home/u/a/b")
|
|
);
|
|
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
|
|
// Unresolvable home leaves the tilde as-is rather than mangling it.
|
|
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
|
|
}
|
|
|
|
#[test]
|
|
fn system_hostname_is_nonempty() {
|
|
let h = system_hostname();
|
|
assert!(!h.is_empty(), "gethostname returned empty string");
|
|
}
|
|
|
|
// NAP-234: tmux_tmpdir precedence is env > file > dirs::runtime_dir().
|
|
// Tested without mutating the process env (which races other tests) by
|
|
// feeding `EnvOverrides` directly.
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_env_beats_file_and_runtime_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
tmux_tmpdir = "/from-file"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let env = EnvOverrides {
|
|
tmux_tmpdir: Some("/from-env".into()),
|
|
..EnvOverrides::default()
|
|
};
|
|
let cfg = load_config(&path, env).unwrap();
|
|
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-env")));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_file_beats_runtime_dir() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[daemon]
|
|
tmux_tmpdir = "/from-file"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-file")));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
std::fs::write(&path, "").unwrap();
|
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
|
// dirs::runtime_dir() is `Some(_)` on any Linux host with
|
|
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
|
|
// falls through to None and tmux uses /tmp; either is acceptable —
|
|
// but on this test runner the env var is always present.
|
|
assert_eq!(cfg.tmux_tmpdir, dirs::runtime_dir());
|
|
}
|
|
}
|