This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/config.rs
T
Miguel PalhasandClaude Opus 5 d39fbdbbfc feat(maestro): drop retained worktrees and header debug toggle
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>
2026-08-31 09:00:52 +01:00

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());
}
}