diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 4855e77..e3c26dd 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -172,6 +172,11 @@ pub struct DaemonSection { /// enable `loginctl enable-linger`. #[serde(default, skip_serializing_if = "Option::is_none")] pub tmux_tmpdir: Option, + /// Recency cap in days for the durable spawn history that backs the spawn + /// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days); + /// `Some(0)` disables history entirely (no log written, no recents read). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawn_history_days: Option, } impl DaemonSection { @@ -186,6 +191,7 @@ impl DaemonSection { && self.private_addr.is_none() && self.pty_replay_bytes.is_none() && self.tmux_tmpdir.is_none() + && self.spawn_history_days.is_none() } } @@ -581,6 +587,7 @@ mod tests { private_addr: Some("10.0.0.7:7070".into()), pty_replay_bytes: Some(131_072), tmux_tmpdir: Some(PathBuf::from("/run/user/1000")), + spawn_history_days: Some(14), }, registry: RegistrySection { bind_addr: Some("0.0.0.0:8080".parse().unwrap()), diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 722dfa8..52adb32 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -106,7 +106,11 @@ async fn run(config_override: Option) -> Result<(), Box> { worktree_mgr, config.fifo_dir(), config.pty_replay_bytes, - ); + ) + .with_spawn_history(crate::spawn_history::SpawnHistory::new( + config.spawn_history_path(), + config.spawn_history_days, + )); let recovered = manager.recover().await?; tracing::info!(recovered, "adopted existing tmux sessions"); diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index f93ed81..6dcfece 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -26,6 +26,10 @@ pub use maestro_config::{ /// 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; + /// Env var that overrides `[shared].registry_url`. pub const ENV_REGISTRY_URL: &str = "MAESTRO_REGISTRY_URL"; /// Env var that overrides `[shared].token`. @@ -93,6 +97,10 @@ pub struct DaemonConfig { /// forces sockets under `p` so bare `tmux ls` in a matching user shell /// sees maestro sessions. pub tmux_tmpdir: Option, + /// 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, } impl DaemonConfig { @@ -104,6 +112,11 @@ impl DaemonConfig { self.state_dir.join("fifos") } + /// JSONL spawn-history log path (#24): `/spawn-history.jsonl`. + pub fn spawn_history_path(&self) -> PathBuf { + self.state_dir.join("spawn-history.jsonl") + } + /// Combine the two perimeter config keys into the typed advertisement the /// daemon broadcasts. A perimeter is only advertised when BOTH the id and /// the private address are set — this is the single place the "both or @@ -237,6 +250,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result Result, sessions: Mutex>, events: broadcast::Sender, } @@ -142,16 +147,41 @@ impl SessionManager { worktree_mgr, fifo_dir, replay_bytes, + spawn_history: None, sessions: Mutex::new(HashMap::new()), events, }), } } + /// Attach a durable spawn-history log (#24). Builder, called once right + /// after construction while the `Arc` is uniquely owned — so + /// [`Arc::get_mut`] succeeds. A no-op (logged) on the off chance the handle + /// was already cloned. + pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self { + match Arc::get_mut(&mut self.inner) { + Some(inner) => inner.spawn_history = Some(history), + None => tracing::error!( + "with_spawn_history called after the SessionManager was cloned; ignoring" + ), + } + self + } + pub fn daemon_id(&self) -> &str { &self.inner.daemon_id } + /// Recent working dirs for the spawn form's picker (#24), newest-first and + /// deduped by dir. Empty when no history is wired or it's disabled. + pub fn recent_dirs(&self) -> Vec { + self.inner + .spawn_history + .as_ref() + .map(|h| h.recent_dirs()) + .unwrap_or_default() + } + pub fn events(&self) -> broadcast::Receiver { self.inner.events.subscribe() } @@ -244,6 +274,13 @@ impl SessionManager { return Err(SessionError::Tmux(e)); } + // Capture spawn-history inputs before `req` is partially moved into + // `session`. `history_cwd` is the *chosen* working dir (the request + // `cwd`), not a generated worktree checkout path — that's what the + // recents picker offers to reuse. + let history_cwd = req.cwd.clone(); + let worktree_branch = req.worktree.as_ref().map(|w| w.branch.clone()); + let session = Session { id, daemon_id: self.inner.daemon_id.clone(), @@ -280,6 +317,27 @@ impl SessionManager { ); } let _ = self.inner.events.send(SessionEvent::Added(session.clone())); + + // Best-effort: log the spawn for the recents picker (#24). A log-write + // failure must never fail an otherwise-successful spawn, so the error + // is only warned. No-op when history is unwired or disabled. + if let Some(history) = &self.inner.spawn_history { + let record = SpawnHistoryRecord { + cwd: history_cwd, + command: session.command.clone(), + name: session + .metadata + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string), + worktree_branch, + at: session.created_at, + }; + if let Err(e) = history.append(&record) { + tracing::warn!(error = %e, "spawn-history append failed (non-fatal)"); + } + } + Ok(session) } diff --git a/crates/maestro-daemon/src/spawn_history.rs b/crates/maestro-daemon/src/spawn_history.rs new file mode 100644 index 0000000..5711939 --- /dev/null +++ b/crates/maestro-daemon/src/spawn_history.rs @@ -0,0 +1,348 @@ +//! Durable per-host spawn history for the spawn form's "recent dirs" picker (#24). +//! +//! Sidecars are deleted on kill, so they can't back a "recently used working +//! directories" list. This module keeps an **append-only JSONL log** at +//! `/spawn-history.jsonl`, one [`SpawnHistoryRecord`] per successful +//! spawn, that survives kills and daemon restarts. +//! +//! - **Write:** [`SpawnHistory::append`] adds a line. Best-effort — callers +//! ignore the error so a log-write failure never fails a spawn. +//! - **Read:** [`SpawnHistory::recent_dirs`] folds the log newest-first, dedups +//! by `cwd`, drops entries past the recency cap, and caps the result count. +//! - **Prune:** [`SpawnHistory::compact`] rewrites the file dropping records +//! past the recency cap; it's called opportunistically from `append` once the +//! file grows past [`COMPACT_THRESHOLD_BYTES`]. +//! +//! The recency cap comes from `[daemon].spawn_history_days` (default 30); `0` +//! disables history entirely — `append` and `recent_dirs` both become no-ops. +//! +//! Like [`crate::dirlist`] / [`crate::gitroot`], the time-dependent logic is +//! factored into `*_as_of(now)` helpers so the fold/prune behaviour is +//! unit-tested deterministically without touching the wall clock. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; + +/// Max [`RecentDir`] entries [`SpawnHistory::recent_dirs`] returns. The picker +/// shows a handful; this bounds the wire payload regardless of log size. +pub const DEFAULT_MAX_RESULTS: usize = 50; + +/// File-size threshold past which [`SpawnHistory::append`] triggers an +/// opportunistic [`SpawnHistory::compact`]. 256 KiB is thousands of records — +/// large enough that compaction is rare, small enough the file never bloats. +pub const COMPACT_THRESHOLD_BYTES: u64 = 256 * 1024; + +/// One logged spawn. Serialized as a single JSON line in the JSONL log. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnHistoryRecord { + /// The working directory the user chose (the original request `cwd`, not a + /// generated worktree checkout path) — what the picker offers to reuse. + pub cwd: PathBuf, + pub command: String, + /// Generated session name, when one was assigned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Worktree branch, when the spawn requested one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_branch: Option, + pub at: DateTime, +} + +/// A deduplicated recent working directory, newest use first. The query result +/// of [`SpawnHistory::recent_dirs`]; #26 maps this to the wire `RecentDir`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentDir { + pub cwd: PathBuf, + /// The command of the most recent spawn in this dir. + pub last_command: String, + pub last_used: DateTime, +} + +/// Append-only JSONL spawn log rooted at a file path, with a recency cap. +/// Cheap to clone — just the path + caps. +#[derive(Debug, Clone)] +pub struct SpawnHistory { + path: PathBuf, + /// Recency cap in days. `0` disables history (every operation is a no-op). + retention_days: u32, + max_results: usize, + compact_threshold_bytes: u64, +} + +impl SpawnHistory { + /// History logged to `path`, keeping records for `retention_days` (`0` + /// disables). Uses [`DEFAULT_MAX_RESULTS`] and [`COMPACT_THRESHOLD_BYTES`]. + pub fn new(path: impl Into, retention_days: u32) -> Self { + Self { + path: path.into(), + retention_days, + max_results: DEFAULT_MAX_RESULTS, + compact_threshold_bytes: COMPACT_THRESHOLD_BYTES, + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Whether history is on. `false` when `retention_days == 0`. + pub fn is_enabled(&self) -> bool { + self.retention_days > 0 + } + + /// Append one record. No-op when disabled. Best-effort: the caller ignores + /// the error so a log-write failure never fails the spawn. Creates the + /// parent directory on demand and triggers opportunistic compaction once + /// the file grows past [`COMPACT_THRESHOLD_BYTES`]. + pub fn append(&self, record: &SpawnHistoryRecord) -> std::io::Result<()> { + if !self.is_enabled() { + return Ok(()); + } + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(record).map_err(std::io::Error::other)?; + line.push('\n'); + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + f.write_all(line.as_bytes())?; + + // Opportunistic prune: rewrite past the size threshold so the log can't + // grow without bound. Best-effort — a compaction failure leaves the + // (valid) appended log in place. + if f.metadata().map(|m| m.len()).unwrap_or(0) > self.compact_threshold_bytes { + if let Err(e) = self.compact() { + tracing::warn!(error = %e, "spawn-history compaction failed (non-fatal)"); + } + } + Ok(()) + } + + /// Recent working dirs, newest use first, deduped by `cwd`, within the + /// recency cap, capped at `max_results`. Empty when disabled or unreadable. + pub fn recent_dirs(&self) -> Vec { + self.recent_dirs_as_of(Utc::now()) + } + + /// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing. + fn recent_dirs_as_of(&self, now: DateTime) -> Vec { + if !self.is_enabled() { + return Vec::new(); + } + let cutoff = now - Duration::days(self.retention_days as i64); + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + // Records are appended oldest→newest; reverse to fold newest-first so + // the first time we see a `cwd` is its most-recent use. + for rec in self.read_records().into_iter().rev() { + if rec.at < cutoff { + continue; + } + if !seen.insert(rec.cwd.clone()) { + continue; + } + out.push(RecentDir { + cwd: rec.cwd, + last_command: rec.command, + last_used: rec.at, + }); + if out.len() >= self.max_results { + break; + } + } + out + } + + /// Rewrite the log keeping only records within the recency cap, preserving + /// chronological (append) order. No-op when disabled. + pub fn compact(&self) -> std::io::Result<()> { + self.compact_as_of(Utc::now()) + } + + /// [`compact`](Self::compact) with an explicit clock for testing. + fn compact_as_of(&self, now: DateTime) -> std::io::Result<()> { + if !self.is_enabled() { + return Ok(()); + } + let cutoff = now - Duration::days(self.retention_days as i64); + let kept: Vec = self + .read_records() + .into_iter() + .filter(|r| r.at >= cutoff) + .collect(); + let mut body = String::new(); + for rec in &kept { + body.push_str(&serde_json::to_string(rec).map_err(std::io::Error::other)?); + body.push('\n'); + } + // Atomic replace (tmp + rename) so a crash mid-rewrite can't truncate + // the log, mirroring [`crate::sidecar`]'s write discipline. + let tmp = self.path.with_extension("jsonl.tmp"); + std::fs::write(&tmp, body)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + /// Parse every line of the log into a record, skipping unreadable/malformed + /// lines (a partial final write, a hand-edit) rather than failing the read. + /// Returns empty when the file is absent. + fn read_records(&self) -> Vec { + let contents = match std::fs::read_to_string(&self.path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + contents + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(cwd: &str, command: &str, at: DateTime) -> SpawnHistoryRecord { + SpawnHistoryRecord { + cwd: PathBuf::from(cwd), + command: command.into(), + name: None, + worktree_branch: None, + at, + } + } + + fn t(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + /// `(history, _tempdir)` over a temp state dir. Keep the dir alive. + fn fixture(retention_days: u32) -> (SpawnHistory, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("spawn-history.jsonl"); + (SpawnHistory::new(path, retention_days), tmp) + } + + #[test] + fn append_then_read_round_trips_record() { + let (h, _tmp) = fixture(30); + let r = SpawnHistoryRecord { + cwd: PathBuf::from("/home/u/git/maestro"), + command: "claude".into(), + name: Some("neon-drifter".into()), + worktree_branch: Some("feat/x".into()), + at: t("2026-06-20T10:00:00Z"), + }; + h.append(&r).unwrap(); + assert_eq!(h.read_records(), vec![r]); + } + + #[test] + fn recent_dirs_dedups_by_cwd_newest_first() { + let (h, _tmp) = fixture(30); + // Same dir spawned twice (older claude, newer zsh) plus a second dir. + h.append(&rec("/a", "claude", t("2026-06-20T08:00:00Z"))) + .unwrap(); + h.append(&rec("/b", "opencode", t("2026-06-20T09:00:00Z"))) + .unwrap(); + h.append(&rec("/a", "zsh", t("2026-06-20T10:00:00Z"))) + .unwrap(); + + let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z")); + // /a is most recent (10:00), then /b (09:00); /a appears once with the + // newest command. + assert_eq!(recents.len(), 2); + assert_eq!(recents[0].cwd, PathBuf::from("/a")); + assert_eq!(recents[0].last_command, "zsh"); + assert_eq!(recents[0].last_used, t("2026-06-20T10:00:00Z")); + assert_eq!(recents[1].cwd, PathBuf::from("/b")); + } + + #[test] + fn recent_dirs_drops_entries_past_recency_cap() { + let (h, _tmp) = fixture(30); + h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z"))) + .unwrap(); + h.append(&rec("/new", "claude", t("2026-06-19T00:00:00Z"))) + .unwrap(); + + // As of 2026-06-20, /old (>30d) is pruned, /new survives. + let recents = h.recent_dirs_as_of(t("2026-06-20T00:00:00Z")); + let dirs: Vec<&Path> = recents.iter().map(|r| r.cwd.as_path()).collect(); + assert_eq!(dirs, [Path::new("/new")]); + } + + #[test] + fn compaction_rewrites_dropping_stale_records() { + let (h, _tmp) = fixture(30); + h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z"))) + .unwrap(); + h.append(&rec("/new", "zsh", t("2026-06-19T00:00:00Z"))) + .unwrap(); + assert_eq!(h.read_records().len(), 2); + + h.compact_as_of(t("2026-06-20T00:00:00Z")).unwrap(); + + let after = h.read_records(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].cwd, PathBuf::from("/new")); + // Order is preserved (chronological), file ends with a newline. + let raw = std::fs::read_to_string(h.path()).unwrap(); + assert!(raw.ends_with('\n')); + } + + #[test] + fn disabled_history_is_a_no_op() { + let (h, _tmp) = fixture(0); + assert!(!h.is_enabled()); + h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z"))) + .unwrap(); + // Nothing written, nothing read. + assert!(!h.path().exists()); + assert!(h.recent_dirs_as_of(t("2026-06-20T11:00:00Z")).is_empty()); + } + + #[test] + fn read_skips_malformed_lines() { + let (h, _tmp) = fixture(30); + h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z"))) + .unwrap(); + // Append a junk line + a blank line by hand. + std::fs::write( + h.path(), + format!( + "{{\"not\":\"a record\"}}\n\n{}\n", + serde_json::to_string(&rec("/a", "claude", t("2026-06-20T10:00:00Z"))).unwrap() + ), + ) + .unwrap(); + // Only the one valid record is parsed. + assert_eq!(h.read_records().len(), 1); + } + + #[test] + fn recent_dirs_caps_result_count() { + let tmp = tempfile::tempdir().unwrap(); + let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30); + h.max_results = 2; + for i in 0..5 { + h.append(&rec( + &format!("/d{i}"), + "claude", + t("2026-06-20T10:00:00Z") + Duration::minutes(i), + )) + .unwrap(); + } + let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z")); + assert_eq!(recents.len(), 2); + // Newest two dirs: /d4 then /d3. + assert_eq!(recents[0].cwd, PathBuf::from("/d4")); + assert_eq!(recents[1].cwd, PathBuf::from("/d3")); + } +} diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index 625504e..4ce6a15 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -11,6 +11,7 @@ use std::thread; use std::time::{Duration, Instant}; use maestro_daemon::sidecar::SidecarStore; +use maestro_daemon::spawn_history::SpawnHistory; use maestro_daemon::tmux::Tmux; use maestro_daemon::worktree::WorktreeManager; use maestro_daemon::SessionManager; @@ -118,18 +119,32 @@ pub struct DaemonFixture { impl DaemonFixture { pub fn new(daemon_id: &str) -> Self { + Self::build(daemon_id, None) + } + + /// Variant whose manager has a durable spawn history (#24) attached, with + /// `retention_days` recency cap, logging to `/spawn-history.jsonl`. + pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self { + Self::build(daemon_id, Some(retention_days)) + } + + fn build(daemon_id: &str, history_days: Option) -> Self { let tmux = TmuxFixture::new(); let state_dir = TempDir::new().unwrap(); let worktree_root = TempDir::new().unwrap(); let sidecar = SidecarStore::new(state_dir.path().join("sessions")); let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf()); - let manager = SessionManager::new( + let mut manager = SessionManager::new( daemon_id.to_string(), tmux.tmux.clone(), sidecar, worktree_mgr, state_dir.path().join("fifos"), ); + if let Some(days) = history_days { + manager = + manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days)); + } Self { tmux, manager, @@ -138,6 +153,14 @@ impl DaemonFixture { } } + fn history_path(state_dir: &TempDir) -> PathBuf { + state_dir.path().join("spawn-history.jsonl") + } + + pub fn spawn_history_path(&self) -> PathBuf { + Self::history_path(&self.state_dir) + } + pub fn sidecar_dir(&self) -> PathBuf { self.state_dir.path().join("sessions") } diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 487d969..8ae0929 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -50,6 +50,60 @@ async fn create_then_kill_round_trip() { .exists()); } +// #24: a successful spawn appends a record to the durable history, which +// `recent_dirs()` then surfaces — proving the create→append→read path end to +// end against a real tmux + on-disk JSONL log. +#[tokio::test] +async fn create_appends_to_spawn_history() { + let fx = DaemonFixture::new_with_history("laptop", 30); + let cwd = TempDir::new().unwrap(); + + let session = fx + .manager + .create(CreateSessionRequest { + daemon_id: "laptop".into(), + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]), + worktree: None, + }) + .await + .expect("create"); + + // The JSONL log exists and the recents query surfaces the chosen dir. + assert!(fx.spawn_history_path().exists()); + let recents = fx.manager.recent_dirs(); + assert_eq!(recents.len(), 1); + assert_eq!(recents[0].cwd, cwd.path()); + assert_eq!(recents[0].last_command, "sleep 30"); + + fx.manager.kill(session.id).await.expect("kill"); + // History is durable — it survives the kill that deletes the sidecar. + assert_eq!(fx.manager.recent_dirs().len(), 1); +} + +// With history disabled (`0` days) or unwired, a spawn logs nothing and the +// recents query is empty — the spawn itself still succeeds. +#[tokio::test] +async fn create_without_history_logs_nothing() { + let fx = DaemonFixture::new("laptop"); + let cwd = TempDir::new().unwrap(); + fx.manager + .create(CreateSessionRequest { + daemon_id: "laptop".into(), + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + assert!(fx.manager.recent_dirs().is_empty()); + assert!(!fx.spawn_history_path().exists()); +} + #[tokio::test] async fn rejects_wrong_daemon_in_request() { let fx = DaemonFixture::new("laptop");