From 132b606830bf2c03a014c86190b67ead7270d46c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:35:26 +0100 Subject: [PATCH] feat(maestro): retained-worktree store in state_dir (#78) Per-entry JSON files under /retained-worktrees/, written like sidecar records: atomic tmp+rename, missing/malformed files tolerated on read. Entries carry path, base repo, branch, reason (force-killed, exited-with-unsaved-work), timestamp; they leave on reclaim or when prune_missing notices the directory was removed by hand. --- crates/maestro-daemon/src/config.rs | 5 + crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/retained_store.rs | 319 ++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 crates/maestro-daemon/src/retained_store.rs diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 4e8684d..31bf2c5 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -103,6 +103,11 @@ impl DaemonConfig { pub fn spawn_history_path(&self) -> PathBuf { self.state_dir.join("spawn-history.jsonl") } + + /// Retained-worktree store directory (#78): `/retained-worktrees/`. + pub fn retained_worktree_dir(&self) -> PathBuf { + self.state_dir.join("retained-worktrees") + } } /// Env-var overrides applied by [`load_config`]. Tests pass explicit diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 68e110c..35e623b 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -19,6 +19,7 @@ pub mod panes; pub mod proc; pub mod projects; pub mod pty; +pub mod retained_store; pub mod session; pub mod sidecar; pub mod spawn_history; diff --git a/crates/maestro-daemon/src/retained_store.rs b/crates/maestro-daemon/src/retained_store.rs new file mode 100644 index 0000000..064ba15 --- /dev/null +++ b/crates/maestro-daemon/src/retained_store.rs @@ -0,0 +1,319 @@ +//! Store of retained worktrees (#78): worktrees the daemon created whose +//! session is gone. +//! +//! One JSON file per entry under `/retained-worktrees/`, written +//! the way `sidecar.rs` writes session records — atomic tmp+rename replace, +//! missing or malformed files tolerated on read. The store survives a daemon +//! restart; it is the recovery path for everything else in the worktree +//! lifecycle. +//! +//! It is not an ownership record: it must never be consulted to decide whether +//! a path may be adopted. Its only job is to remember what the daemon is +//! holding so it can be listed and reclaimed. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum RetainedStoreError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +/// Why a worktree outlived its session. Kebab-case on the wire; new variants +/// are additive but clients should render unknown reasons generically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RetainedReason { + /// The session was killed with `--force` while its worktree held unsaved + /// work. + ForceKilled, + /// The session ended by itself and its worktree held unsaved work. + ExitedWithUnsavedWork, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RetainedWorktree { + pub id: Uuid, + pub path: PathBuf, + pub base_repo: PathBuf, + pub branch: String, + pub reason: RetainedReason, + pub retained_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RetainedStore { + dir: PathBuf, +} + +impl RetainedStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path_for(&self, id: Uuid) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + /// Record a retained worktree and return the entry that was written. + pub fn retain( + &self, + path: &Path, + base_repo: &Path, + branch: &str, + reason: RetainedReason, + ) -> Result { + let entry = RetainedWorktree { + id: Uuid::new_v4(), + path: path.to_path_buf(), + base_repo: base_repo.to_path_buf(), + branch: branch.to_string(), + reason, + retained_at: Utc::now(), + }; + self.overwrite(&entry)?; + Ok(entry) + } + + /// Atomically write `entry`. Creates the directory if it does not exist. + pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> { + std::fs::create_dir_all(&self.dir)?; + let final_path = self.path_for(entry.id); + let tmp = self.dir.join(format!("{}.json.tmp", entry.id)); + let json = serde_json::to_vec_pretty(entry)?; + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &final_path)?; + Ok(()) + } + + /// Drop one entry. No-op when the id is unknown — reclaiming an entry the + /// store already lost (e.g. pruned) must not fail. + pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> { + match std::fs::remove_file(self.path_for(id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// Read every entry. Files that are missing or malformed are skipped + /// silently: a corrupt record must not hide the others, and an empty or + /// absent directory simply means nothing is retained. + pub fn load_all(&self) -> Result, RetainedStoreError> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(&self.dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if let Ok(worktree) = serde_json::from_slice::(&bytes) { + out.push(worktree); + } + } + out.sort_by_key(|w| w.retained_at); + Ok(out) + } + + /// Drop entries whose worktree directory no longer exists — somebody + /// removed it by hand. Returns the ids that were pruned. + pub fn prune_missing(&self) -> Result, RetainedStoreError> { + let mut pruned = Vec::new(); + for entry in self.load_all()? { + if !entry.path.exists() { + self.remove(entry.id)?; + pruned.push(entry.id); + } + } + Ok(pruned) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use std::path::PathBuf; + + fn tmp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn sample_path(root: &std::path::Path, name: &str) -> PathBuf { + let p = root.join(name); + std::fs::create_dir_all(&p).expect("mkdir"); + p + } + + #[test] + fn retain_then_load_round_trips_every_field() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let repo = sample_path(dir.path(), "repo"); + + let entry = store + .retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled) + .expect("retain"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all, vec![entry.clone()]); + assert_eq!(entry.path, wt); + assert_eq!(entry.base_repo, repo); + assert_eq!(entry.branch, "feature/login"); + assert_eq!(entry.reason, RetainedReason::ForceKilled); + let age = Utc::now() - entry.retained_at; + assert!(age >= Duration::zero() && age < Duration::minutes(1)); + } + + #[test] + fn reason_serializes_kebab_case() { + let json = + serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize"); + assert_eq!(json, r#""exited-with-unsaved-work""#); + } + + #[test] + fn entries_survive_a_restart() { + let dir = tmp_dir(); + let wt = sample_path(dir.path(), "wt"); + + let first = RetainedStore::new(dir.path()); + let entry = first + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + // A fresh store over the same state_dir — what a daemon restart sees. + let second = RetainedStore::new(dir.path()); + assert_eq!(second.load_all().expect("load_all"), vec![entry]); + } + + #[test] + fn missing_store_reads_as_empty() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path().join("does-not-exist")); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + } + + #[test] + fn malformed_entries_are_skipped_silently() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + store + .retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled) + .expect("retain"); + std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].branch, "kept"); + } + + #[test] + fn ids_are_unique() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let a = store + .retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled) + .expect("retain"); + let b = store + .retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled) + .expect("retain"); + assert_ne!(a.id, b.id); + } + + #[test] + fn remove_drops_the_entry() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let entry = store + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + store.remove(entry.id).expect("remove"); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + + // Removing an unknown id is a no-op, not an error. + store.remove(entry.id).expect("remove again"); + } + + #[test] + fn load_all_is_sorted_by_time() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let mut first = store + .retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled) + .expect("retain"); + first.retained_at -= Duration::hours(1); + store.overwrite(&first).expect("rewrite"); + + let second = store + .retain( + dir.path(), + dir.path(), + "second", + RetainedReason::ForceKilled, + ) + .expect("retain"); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["first", "second"]); + let _ = second; + } + + #[test] + fn prune_missing_drops_gone_directories_only() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let alive = sample_path(dir.path(), "alive"); + let gone = sample_path(dir.path(), "gone"); + let keep = store + .retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled) + .expect("retain"); + let drop = store + .retain( + &gone, + dir.path(), + "drop", + RetainedReason::ExitedWithUnsavedWork, + ) + .expect("retain"); + std::fs::remove_dir_all(&gone).expect("rm"); + + let pruned = store.prune_missing().expect("prune_missing"); + assert_eq!(pruned, vec![drop.id]); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["keep"]); + let _ = keep; + } +}