diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 9 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 27 | ||||
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 342 | ||||
| -rw-r--r-- | crates/dinggerd/src/snapshot.rs | 412 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 10 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 4 | ||||
| -rwxr-xr-x | tests/e2e.sh | 73 |
8 files changed, 876 insertions, 9 deletions
@@ -57,6 +57,14 @@ than a poll is still a completion. agents are in the menu's order, so ↩ jumps to the top waiting agent. It takes the keyboard without activating dingger, so focus returns where it was. +- **Restore**: dingger saves your tmux layout as it changes (sessions, + windows, panes, layouts, directories) under + `~/.local/state/dingger/snapshots/`. After a crash, shutdown, or + `kill-server`, `dingger restore` brings back what is missing (`--dry-run` + to preview, `--list` for saved snapshots, `--session` for some). Sessions + that exist are left alone, except a bare one of the same name (what a + terminal creates on startup), which is replaced with its clients moved + over. - All shortcuts live in `~/.config/dingger/config.toml` (menu bar, Settings…), reloaded on save. - **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 524fb2d..46077cf 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -31,14 +31,7 @@ pub fn socket_path() -> PathBuf { if let Some(path) = std::env::var_os("DINGGER_AGENT_SOCKET") { return PathBuf::from(path); } - let state = std::env::var_os("DINGGER_STATE_DIR") - .map(PathBuf::from) - .or_else(|| { - std::env::var_os("HOME") - .map(|home| PathBuf::from(home).join(".local").join("state").join("dingger")) - }) - .unwrap_or_else(|| PathBuf::from(".dingger")); - state.join("agent.sock") + crate::snapshot::state_dir().join("agent.sock") } #[derive(Debug, Default)] diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 3707d51..2129bd2 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -5,6 +5,8 @@ mod integrations; mod manifest_update; mod names; mod notify; +mod restore; +mod snapshot; mod tmux; mod watcher; @@ -52,6 +54,24 @@ enum Command { }, /// Wake the watcher so focus changes show up immediately (tmux hooks). Poke, + /// Restore tmux sessions saved before tmux last stopped (a crash, + /// shutdown, or kill-server): windows, panes, layouts, directories. + /// Only adds what is missing; a bare session of the same name (what a + /// terminal creates on startup) is replaced, its clients moved over. + Restore { + /// Show what would happen without changing anything. + #[arg(long)] + dry_run: bool, + /// List the restore candidate and saved snapshots. + #[arg(long)] + list: bool, + /// Restore from this snapshot file instead of the candidate. + #[arg(long)] + from: Option<String>, + /// Restore only these sessions. + #[arg(long = "session")] + sessions: Vec<String>, + }, /// Install agent integrations (hook extensions that report state). Install { /// Agent to install the integration for. @@ -112,6 +132,13 @@ fn main() { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); } + Command::Restore { dry_run, list, from, sessions } => { + let result = if list { restore::list() } else { restore::run(from.as_deref(), &sessions, dry_run) }; + if let Err(err) = result { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } Command::Install { agent } => { if let Err(err) = integrations::install(&agent) { eprintln!("dingger: {err}"); diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs new file mode 100644 index 0000000..6ed19a7 --- /dev/null +++ b/crates/dinggerd/src/restore.rs @@ -0,0 +1,342 @@ +//! `dingger restore`: rebuild tmux sessions from a snapshot. +//! +//! Only adds what is missing. A session that exists is left alone, except a +//! bare one (one window, one pane, only a shell): that is what a terminal +//! creates on startup (Ghostty's `tmux new-session -As 0`), so the saved +//! session replaces it and its clients move over without detaching. +//! +//! Each session is rebuilt with its windows (index, name, automatic-rename), +//! panes (directory, in order), exact layout, zoom, and active window and +//! pane. Everything is addressed by tmux id, so names with `.` or `:` work. + +use crate::snapshot::{self, SessionSnap, Snapshot, WindowSnap}; +use crate::tmux; + +/// What restore does with a saved session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Plan { + Create, + /// Replace an existing bare session of the same name. + Replace, + /// It exists and has been used: leave it alone. + Keep, +} + +/// A session on the running server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Existing { + pub name: String, + /// One window with one pane that runs only a shell. + pub bare: bool, +} + +pub fn plan(snapshot: &Snapshot, existing: &[Existing]) -> Vec<(String, Plan)> { + snapshot + .sessions + .iter() + .map(|session| { + let plan = match existing.iter().find(|e| e.name == session.name) { + None => Plan::Create, + // A saved single shell has nothing to add over a bare one. + Some(e) if e.bare && !saved_bare(session) => Plan::Replace, + Some(_) => Plan::Keep, + }; + (session.name.clone(), plan) + }) + .collect() +} + +/// Sessions restore would create or replace. +pub fn missing(snapshot: &Snapshot, existing: &[Existing]) -> Vec<String> { + plan(snapshot, existing) + .into_iter() + .filter(|(_, plan)| *plan != Plan::Keep) + .map(|(name, _)| name) + .collect() +} + +fn saved_bare(session: &SessionSnap) -> bool { + matches!(&session.windows[..], [window] if matches!(&window.panes[..], [pane] if pane.agent.is_none())) +} + +fn is_shell(command: &str) -> bool { + matches!( + command.trim_start_matches('-'), + "zsh" | "bash" | "fish" | "sh" | "dash" | "ksh" | "tcsh" | "csh" | "nu" | "elvish" | "xonsh" + ) +} + +/// The running server's sessions; empty when tmux is not running. +pub fn existing_sessions() -> Vec<Existing> { + let Ok(out) = tmux::run(&[ + "list-panes", + "-a", + "-F", + "#{session_name}\t#{session_windows}\t#{window_panes}\t#{pane_current_command}", + ]) else { + return Vec::new(); + }; + let mut sessions: Vec<Existing> = Vec::new(); + for line in out.lines() { + let f: Vec<&str> = line.split('\t').collect(); + if f.len() < 4 || sessions.iter().any(|s| s.name == f[0]) { + continue; + } + sessions.push(Existing { + name: f[0].to_string(), + bare: f[1] == "1" && f[2] == "1" && is_shell(f[3]), + }); + } + sessions +} + +fn first_line(out: String) -> String { + out.lines().next().unwrap_or_default().trim().to_string() +} + +/// Build one window's panes, layout, and options. `pane` is its first pane. +fn build_window(window_id: &str, first_pane: String, window: &WindowSnap) -> Result<(), String> { + let width = window.width.max(10).to_string(); + let height = window.height.max(5).to_string(); + // The saved size, so the layout applies exactly; unset afterwards so the + // window follows its clients again. + tmux::run(&["resize-window", "-t", window_id, "-x", &width, "-y", &height])?; + let mut panes = vec![first_pane]; + for pane in window.panes.iter().skip(1) { + // Split the newest pane: tmux lists a split right after its source, + // so panes keep the saved order. Rebalance to make room for the next. + let last = panes.last().cloned().unwrap_or_default(); + let id = first_line(tmux::run(&[ + "split-window", "-d", "-t", &last, "-c", &pane.cwd, "-P", "-F", "#{pane_id}", + ])?); + panes.push(id); + let _ = tmux::run(&["select-layout", "-t", window_id, "tiled"]); + } + if panes.len() > 1 { + if let Err(err) = tmux::run(&["select-layout", "-t", window_id, &window.layout]) { + tracing::warn!(%err, "saved layout did not apply; keeping a tiled layout"); + } + } + tmux::run(&["set-option", "-w", "-u", "-t", window_id, "window-size"])?; + if window.automatic_rename { + tmux::run(&["set-option", "-w", "-t", window_id, "automatic-rename", "on"])?; + } + if let Some(position) = window.panes.iter().position(|pane| pane.active) { + if let Some(id) = panes.get(position) { + tmux::run(&["select-pane", "-t", id])?; + if window.zoomed && panes.len() > 1 { + tmux::run(&["resize-pane", "-Z", "-t", id])?; + } + } + } + Ok(()) +} + +/// Create `session` under `name`; returns its session id. +fn build_session(session: &SessionSnap, name: &str) -> Result<String, String> { + let Some(first) = session.windows.first() else { + return Err(format!("{}: no windows saved", session.name)); + }; + let cwd = first.panes.first().map(|pane| pane.cwd.as_str()).unwrap_or("~"); + let created = first_line(tmux::run(&[ + "new-session", "-d", "-s", name, "-n", &first.name, "-c", cwd, + "-x", &first.width.max(10).to_string(), "-y", &first.height.max(5).to_string(), + "-P", "-F", "#{session_id}\t#{window_id}\t#{window_index}\t#{pane_id}", + ])?); + let ids: Vec<&str> = created.split('\t').collect(); + let [session_id, first_window, first_index, first_pane] = ids[..] else { + return Err(format!("unexpected new-session output: {created}")); + }; + let session_id = session_id.to_string(); + if first_index != first.index.to_string() { + tmux::run(&["move-window", "-s", first_window, "-t", &format!("{session_id}:{}", first.index)])?; + } + build_window(first_window, first_pane.to_string(), first)?; + for window in session.windows.iter().skip(1) { + let cwd = window.panes.first().map(|pane| pane.cwd.as_str()).unwrap_or("~"); + let created = first_line(tmux::run(&[ + "new-window", "-d", "-t", &format!("{session_id}:{}", window.index), "-n", &window.name, + "-c", cwd, "-P", "-F", "#{window_id}\t#{pane_id}", + ])?); + let Some((window_id, pane_id)) = created.split_once('\t') else { + return Err(format!("unexpected new-window output: {created}")); + }; + build_window(window_id, pane_id.to_string(), window)?; + } + if let Some(active) = session.windows.iter().find(|window| window.active) { + tmux::run(&["select-window", "-t", &format!("{session_id}:{}", active.index)])?; + } + Ok(session_id) +} + +/// Replace a bare session: build the saved one beside it, move its clients +/// over, then remove it and take its name. +fn replace_session(session: &SessionSnap) -> Result<(), String> { + let temporary = format!("{}-restoring-{}", session.name, std::process::id()); + let new_id = build_session(session, &temporary)?; + let clients = tmux::run(&["list-clients", "-F", "#{client_name}\t#{session_name}"]).unwrap_or_default(); + for line in clients.lines() { + if let Some((client, attached)) = line.split_once('\t') { + if attached == session.name { + tmux::run(&["switch-client", "-c", client, "-t", &new_id])?; + } + } + } + tmux::run(&["kill-session", "-t", &format!("={}", session.name)])?; + tmux::run(&["rename-session", "-t", &new_id, &session.name]).map(|_| ()) +} + +fn windows_and_panes(session: &SessionSnap) -> String { + let panes: usize = session.windows.iter().map(|window| window.panes.len()).sum(); + format!("{} windows, {panes} panes", session.windows.len()) +} + +/// The snapshot to restore: `from`, or the restore candidate. +fn source(from: Option<&str>) -> Result<Snapshot, String> { + match from { + Some(path) => snapshot::load(std::path::Path::new(path)).ok_or_else(|| format!("cannot read {path}")), + None => snapshot::candidate() + .map(|candidate| candidate.snapshot) + .ok_or_else(|| "nothing to restore: no saved layout from an earlier tmux server".to_string()), + } +} + +pub fn list() -> Result<(), String> { + match snapshot::candidate() { + Some(candidate) => { + let missing = missing(&candidate.snapshot, &existing_sessions()); + println!( + "restore candidate ({}), saved {}: {}; missing now: {}", + candidate.status, + format_time(candidate.snapshot.saved_at), + candidate.snapshot.summary(), + if missing.is_empty() { "none".to_string() } else { missing.join(", ") } + ); + } + None => println!("no restore candidate"), + } + for path in snapshot::history().iter().rev() { + if let Some(snapshot) = snapshot::load(path) { + println!("{} {} {}", path.display(), format_time(snapshot.saved_at), snapshot.summary()); + } + } + Ok(()) +} + +fn format_time(ms: u64) -> String { + std::process::Command::new("date") + .args(["-r", &(ms / 1000).to_string(), "+%Y-%m-%d %H:%M"]) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .unwrap_or_else(|_| ms.to_string()) +} + +/// Restore missing sessions (all, or only `only`). +pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), String> { + let snapshot = source(from)?; + let existing = existing_sessions(); + let mut failures = 0; + for (name, plan) in plan(&snapshot, &existing) { + if !only.is_empty() && !only.contains(&name) { + continue; + } + let Some(session) = snapshot.sessions.iter().find(|s| s.name == name) else { + continue; + }; + let what = windows_and_panes(session); + let verb = match plan { + Plan::Keep => { + println!("keep {name}: it exists"); + continue; + } + Plan::Create => "create", + Plan::Replace => "replace", + }; + if dry_run { + println!("{verb:<8} {name}: {what}"); + continue; + } + let result = match plan { + Plan::Replace => replace_session(session), + _ => build_session(session, &name).map(|_| ()), + }; + match result { + Ok(()) => println!("{verb:<8} {name}: {what}"), + Err(err) => { + failures += 1; + println!("FAILED {name}: {err}"); + } + } + } + if dry_run || from.is_some() { + return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }; + } + if missing(&snapshot, &existing_sessions()).is_empty() { + let _ = snapshot::set_candidate_status("restored"); + } + if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snapshot::{PaneSnap, ServerId, SessionSnap}; + + fn window(panes: usize) -> WindowSnap { + WindowSnap { + index: 0, + name: "w".into(), + layout: String::new(), + width: 80, + height: 24, + active: true, + zoomed: false, + automatic_rename: false, + panes: (0..panes) + .map(|i| PaneSnap { index: i as u32, cwd: "/tmp".into(), active: i == 0, agent: None }) + .collect(), + } + } + + fn snapshot(names: &[&str]) -> Snapshot { + Snapshot { + version: 1, + saved_at: 1, + server: ServerId { pid: 1, start_time: 1 }, + sessions: names + .iter() + .map(|name| SessionSnap { name: name.to_string(), windows: vec![window(2)] }) + .collect(), + } + } + + #[test] + fn creates_missing_replaces_bare_and_keeps_used_sessions() { + let existing = [ + Existing { name: "0".into(), bare: true }, + Existing { name: "keep".into(), bare: false }, + Existing { name: "new".into(), bare: true }, + ]; + assert_eq!( + plan(&snapshot(&["0", "keep", "work"]), &existing), + vec![("0".into(), Plan::Replace), ("keep".into(), Plan::Keep), ("work".into(), Plan::Create)] + ); + assert_eq!(missing(&snapshot(&["0", "keep", "work"]), &existing), vec!["0", "work"]); + assert!(missing(&snapshot(&["keep"]), &existing).is_empty()); + } + + #[test] + fn a_saved_single_shell_does_not_replace_a_bare_session() { + let mut saved = snapshot(&["0"]); + saved.sessions[0].windows = vec![window(1)]; + let existing = [Existing { name: "0".into(), bare: true }]; + assert_eq!(plan(&saved, &existing), vec![("0".into(), Plan::Keep)]); + assert!(missing(&saved, &existing).is_empty(), "or the offer would never go away"); + } + + #[test] + fn shells_are_recognized() { + assert!(is_shell("zsh") && is_shell("-zsh") && is_shell("bash") && is_shell("fish")); + assert!(!is_shell("nvim") && !is_shell("node") && !is_shell("tmux")); + } +} diff --git a/crates/dinggerd/src/snapshot.rs b/crates/dinggerd/src/snapshot.rs new file mode 100644 index 0000000..db5ba35 --- /dev/null +++ b/crates/dinggerd/src/snapshot.rs @@ -0,0 +1,412 @@ +//! Snapshots of the tmux server's layout, for restore after a crash or +//! shutdown. +//! +//! The watcher captures sessions, windows, and panes every tick and saves a +//! snapshot when something changed: +//! +//! - `snapshots/<saved ms>.json`: a new file when the layout changes, the +//! current file rewritten when only details change (agent states); the +//! last [`HISTORY`] files are kept. +//! - Nothing is saved while tmux is not running or has no sessions, so a +//! dying server cannot overwrite the last good snapshot. +//! - `restore.json`: when a different tmux server is running (or none), the +//! previous server's last snapshot becomes the restore candidate, until it +//! is restored or dismissed. It is pinned before the new server's own +//! snapshots start, so they never replace it. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::tmux; + +/// Snapshot files kept. +const HISTORY: usize = 50; + +pub fn state_dir() -> PathBuf { + std::env::var_os("DINGGER_STATE_DIR") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| PathBuf::from(home).join(".local").join("state").join("dingger")) + }) + .unwrap_or_else(|| PathBuf::from(".dingger")) +} + +fn snapshot_dir() -> PathBuf { + state_dir().join("snapshots") +} + +fn candidate_path() -> PathBuf { + state_dir().join("restore.json") +} + +/// A tmux server instance: its pid and start time (pids are reused). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerId { + pub pid: u32, + pub start_time: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Snapshot { + pub version: u32, + /// Unix milliseconds. + pub saved_at: u64, + pub server: ServerId, + pub sessions: Vec<SessionSnap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSnap { + pub name: String, + pub windows: Vec<WindowSnap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowSnap { + pub index: u32, + pub name: String, + /// tmux's layout string (`select-layout` reapplies it). + pub layout: String, + pub width: u32, + pub height: u32, + pub active: bool, + pub zoomed: bool, + pub automatic_rename: bool, + pub panes: Vec<PaneSnap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSnap { + pub index: u32, + pub cwd: String, + pub active: bool, + /// The agent running in the pane, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<AgentSnap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSnap { + /// Harness, e.g. `pi`. + pub agent: String, + #[serde(default)] + pub name: String, + /// Display status when saved (`working` means a turn was interrupted). + #[serde(default)] + pub state: String, +} + +/// One window's layout: session, index, name, layout, and pane directories. +type WindowKey = (String, u32, String, String, Vec<(u32, String)>); + +impl Snapshot { + /// The layout alone: what decides between a new file and a rewrite. + fn layout_key(&self) -> Vec<WindowKey> { + self.sessions + .iter() + .flat_map(|session| { + session.windows.iter().map(|window| { + ( + session.name.clone(), + window.index, + window.name.clone(), + window.layout.clone(), + window.panes.iter().map(|pane| (pane.index, pane.cwd.clone())).collect(), + ) + }) + }) + .collect() + } + + /// Same content, ignoring when it was saved. + fn same_content(&self, other: &Snapshot) -> bool { + self.server == other.server && self.sessions == other.sessions + } + + pub fn summary(&self) -> String { + let windows: usize = self.sessions.iter().map(|s| s.windows.len()).sum(); + let panes: usize = self.sessions.iter().flat_map(|s| &s.windows).map(|w| w.panes.len()).sum(); + let agents = self + .sessions + .iter() + .flat_map(|s| &s.windows) + .flat_map(|w| &w.panes) + .filter(|p| p.agent.is_some()) + .count(); + format!( + "{} sessions, {windows} windows, {panes} panes, {agents} agents", + self.sessions.len() + ) + } +} + +/// The running server, or `None` when tmux is not running. +pub fn server_id() -> Option<ServerId> { + let out = tmux::run(&["display-message", "-p", "#{pid}\t#{start_time}"]).ok()?; + let (pid, start) = out.trim().split_once('\t')?; + Some(ServerId { + pid: pid.parse().ok()?, + start_time: start.parse().ok()?, + }) +} + +/// Capture the running server's layout; `None` when tmux is not running or +/// has no sessions. +pub fn capture(now_ms: u64) -> Option<Snapshot> { + let server = server_id()?; + let format = [ + "#{session_name}", + "#{window_index}", + "#{window_name}", + "#{window_layout}", + "#{window_width}", + "#{window_height}", + "#{window_active}", + "#{window_zoomed_flag}", + "#{automatic-rename}", + "#{pane_index}", + "#{pane_active}", + "#{pane_current_path}", + "#{@agent}", + "#{@agent_name}", + "#{@agent_state}", + ] + .join("\t"); + let out = tmux::run(&["list-panes", "-a", "-F", &format]).ok()?; + let mut sessions: Vec<SessionSnap> = Vec::new(); + for line in out.lines() { + let f: Vec<&str> = line.split('\t').collect(); + if f.len() < 15 { + continue; + } + let number = |s: &str| s.parse::<u32>().unwrap_or(0); + if sessions.last().map(|s| s.name != f[0]).unwrap_or(true) { + sessions.push(SessionSnap { name: f[0].to_string(), windows: Vec::new() }); + } + let session = sessions.last_mut()?; + let index = number(f[1]); + if session.windows.last().map(|w| w.index != index).unwrap_or(true) { + session.windows.push(WindowSnap { + index, + name: f[2].to_string(), + layout: f[3].to_string(), + width: number(f[4]), + height: number(f[5]), + active: f[6] == "1", + zoomed: f[7] == "1", + automatic_rename: f[8] == "1", + panes: Vec::new(), + }); + } + let window = session.windows.last_mut()?; + window.panes.push(PaneSnap { + index: number(f[9]), + cwd: f[11].to_string(), + active: f[10] == "1", + agent: (!f[12].is_empty()).then(|| AgentSnap { + agent: f[12].to_string(), + name: f[13].to_string(), + state: f[14].to_string(), + }), + }); + } + (!sessions.is_empty()).then_some(Snapshot { version: 1, saved_at: now_ms, server, sessions }) +} + +fn write_atomic(path: &Path, value: &impl Serialize) -> Result<(), String> { + let json = serde_json::to_string_pretty(value).map_err(|err| err.to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| format!("mkdir {}: {err}", parent.display()))?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json).map_err(|err| format!("write {}: {err}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|err| format!("rename {}: {err}", path.display())) +} + +fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +/// Saved snapshot files, oldest first. +pub fn history() -> Vec<PathBuf> { + let mut files: Vec<PathBuf> = std::fs::read_dir(snapshot_dir()) + .map(|entries| { + entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect() + }) + .unwrap_or_default(); + // Names are zero-padded milliseconds: lexical order is time order. + files.sort(); + files +} + +pub fn load(path: &Path) -> Option<Snapshot> { + read_json(path) +} + +/// What to do with a fresh capture, given the latest saved snapshot. +#[derive(Debug, PartialEq, Eq)] +pub enum SaveAction { + Nothing, + /// Rewrite the latest file: only details changed. + Rewrite, + /// A new history file: the layout changed, or a different server. + New, +} + +pub fn save_action(latest: Option<&Snapshot>, fresh: &Snapshot) -> SaveAction { + match latest { + None => SaveAction::New, + Some(latest) if latest.same_content(fresh) => SaveAction::Nothing, + Some(latest) if latest.server == fresh.server && latest.layout_key() == fresh.layout_key() => { + SaveAction::Rewrite + } + Some(_) => SaveAction::New, + } +} + +/// The restore candidate and what happened to it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Candidate { + /// `offered`, `restored`, or `dismissed`. + pub status: String, + pub snapshot: Snapshot, +} + +pub fn candidate() -> Option<Candidate> { + read_json(&candidate_path()) +} + +pub fn set_candidate_status(status: &str) -> Result<(), String> { + let Some(mut candidate) = candidate() else { + return Err("no restore candidate".into()); + }; + candidate.status = status.to_string(); + write_atomic(&candidate_path(), &candidate) +} + +/// Whether the latest snapshot should become the restore candidate: it +/// belongs to a server that is no longer running, and is not the candidate +/// already (whatever became of it). +pub fn should_pin(latest: &Snapshot, running: Option<&ServerId>, current: Option<&Candidate>) -> bool { + running != Some(&latest.server) + && current.map(|c| c.snapshot.server != latest.server).unwrap_or(true) +} + +/// Keeps snapshots current; called by the watcher every loop, whether or not +/// tmux is running. +#[derive(Default)] +pub struct Recorder { + latest: Option<(PathBuf, Snapshot)>, + loaded: bool, +} + +impl Recorder { + pub fn tick(&mut self, now_ms: u64) { + if !self.loaded { + self.loaded = true; + self.latest = history().last().and_then(|path| Some((path.clone(), load(path)?))); + } + let running = server_id(); + // Before saving anything for a new server: pin the old one's layout. + if let Some((_, latest)) = self.latest.as_ref().filter(|(_, s)| running.as_ref() != Some(&s.server)) { + let current = candidate(); + if should_pin(latest, running.as_ref(), current.as_ref()) { + let candidate = Candidate { status: "offered".into(), snapshot: latest.clone() }; + match write_atomic(&candidate_path(), &candidate) { + Ok(()) => info!(snapshot = %latest.summary(), "restore candidate saved"), + Err(err) => warn!(%err, "failed to save the restore candidate"), + } + } + } + let Some(fresh) = running.and_then(|_| capture(now_ms)) else { + return; + }; + let action = save_action(self.latest.as_ref().map(|(_, s)| s), &fresh); + let path = match (&action, &self.latest) { + (SaveAction::Nothing, _) => return, + (SaveAction::Rewrite, Some((path, _))) => path.clone(), + _ => snapshot_dir().join(format!("{:015}.json", fresh.saved_at)), + }; + if let Err(err) = write_atomic(&path, &fresh) { + warn!(%err, "failed to save snapshot"); + return; + } + if action == SaveAction::New { + info!(snapshot = %fresh.summary(), "snapshot saved"); + let files = history(); + for old in files.iter().take(files.len().saturating_sub(HISTORY)) { + let _ = std::fs::remove_file(old); + } + } + self.latest = Some((path, fresh)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snap(pid: u32, windows: &[(&str, &str)], state: &str) -> Snapshot { + Snapshot { + version: 1, + saved_at: 1, + server: ServerId { pid, start_time: 100 }, + sessions: vec![SessionSnap { + name: "s".into(), + windows: windows + .iter() + .enumerate() + .map(|(i, (name, layout))| WindowSnap { + index: i as u32, + name: name.to_string(), + layout: layout.to_string(), + width: 80, + height: 24, + active: i == 0, + zoomed: false, + automatic_rename: false, + panes: vec![PaneSnap { + index: 0, + cwd: "/tmp".into(), + active: true, + agent: Some(AgentSnap { agent: "pi".into(), name: String::new(), state: state.into() }), + }], + }) + .collect(), + }], + } + } + + #[test] + fn saves_new_files_for_layout_changes_and_rewrites_for_details() { + let base = snap(1, &[("a", "L1")], "idle"); + assert_eq!(save_action(None, &base), SaveAction::New); + let mut later = base.clone(); + later.saved_at = 99; + assert_eq!(save_action(Some(&base), &later), SaveAction::Nothing, "time alone is no change"); + assert_eq!(save_action(Some(&base), &snap(1, &[("a", "L1")], "working")), SaveAction::Rewrite); + assert_eq!(save_action(Some(&base), &snap(1, &[("a", "L2")], "idle")), SaveAction::New); + assert_eq!(save_action(Some(&base), &snap(1, &[("a", "L1"), ("b", "L1")], "idle")), SaveAction::New); + assert_eq!(save_action(Some(&base), &snap(2, &[("a", "L1")], "idle")), SaveAction::New, "new server"); + } + + #[test] + fn pins_the_previous_servers_snapshot_once() { + let old = snap(1, &[("a", "L1")], "idle"); + let old_server = old.server.clone(); + let new_server = ServerId { pid: 2, start_time: 200 }; + assert!(!should_pin(&old, Some(&old_server), None), "its server still runs"); + assert!(should_pin(&old, Some(&new_server), None), "a new server"); + assert!(should_pin(&old, None, None), "no server"); + let pinned = Candidate { status: "dismissed".into(), snapshot: old.clone() }; + assert!(!should_pin(&old, None, Some(&pinned)), "already the candidate, whatever its status"); + let older = Candidate { status: "restored".into(), snapshot: snap(0, &[], "") }; + assert!(should_pin(&old, None, Some(&older)), "replaces an older candidate"); + } +} diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 48d9544..4e2b370 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -59,6 +59,11 @@ fn tmux_binary() -> &'static std::path::Path { }) } +/// Run a tmux command against the server dingger watches. +pub fn run(args: &[&str]) -> Result<String, String> { + run_tmux(args) +} + fn run_tmux(args: &[&str]) -> Result<String, String> { let mut command = Command::new(tmux_binary()); // -u forces UTF-8 mode: without it, tmux mangles format output in @@ -70,6 +75,11 @@ fn run_tmux(args: &[&str]) -> Result<String, String> { if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") { command.arg("-L").arg(socket); } + // The config a server started by dingger (restore) loads; tests use + // /dev/null. + if let Some(config) = std::env::var_os("DINGGER_TMUX_CONFIG") { + command.arg("-f").arg(config); + } let output = command .args(args) .output() diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index e87a7bf..a2f5ef1 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -180,6 +180,7 @@ pub fn advance( pub struct Watcher { tracks: HashMap<String, PaneTrack>, + snapshots: crate::snapshot::Recorder, /// Last published (queue, blocked, done, working); avoids rewriting the /// global options every tick. last_published: Option<(String, usize, usize, usize)>, @@ -192,6 +193,7 @@ impl Watcher { pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), + snapshots: Default::default(), last_published: None, interval_secs: interval_secs.max(1), sounds, @@ -221,6 +223,8 @@ impl Watcher { had_server = false; } } + // After the tick, so snapshots carry the states it just wrote. + self.snapshots.tick(unix_now()); debug!(ms = started.elapsed().as_millis() as u64, "tick"); hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } diff --git a/tests/e2e.sh b/tests/e2e.sh index 983912c..425fdf3 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -29,13 +29,15 @@ PASSES=0 # Every dingger invocation below (daemon, focus, poke, menu bar) targets the # isolated server and the test daemon's hook socket. -export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" +export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state" t() { tmux -u -L "$SOCK" "$@"; } cleanup() { [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null + [ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null t kill-server 2>/dev/null + tmux -L "$SOCK-restore" kill-server 2>/dev/null rm -rf "$DIR" } trap cleanup EXIT @@ -345,6 +347,75 @@ else fail "menu bar binary not found at $MENUBAR" fi +# --- 16. restore after the tmux server goes away ------------------------------ +# A second server and daemon. "viewer" hosts a client the way a terminal +# does: Ghostty runs `tmux new-session -As 0`. +echo "16. restore" +R="$SOCK-restore" +STATE2="$DIR/state2" +r() { tmux -u -L "$R" "$@"; } +renv() { env DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" DINGGER_STATE_DIR="$STATE2" \ + DINGGER_TMUX_CONFIG=/dev/null "$@"; } +W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three" +start_viewer() { + r -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \ + send-keys -t viewer "exec env -u TMUX tmux -u -L $R new-session -As 0 -c $W/one" Enter + for _ in $(seq 50); do r has-session -t =0 2>/dev/null && [ -n "$(r list-clients)" ] && return; sleep 0.1; done + echo "viewer client did not attach" +} +start_viewer +P0=$(r display -p -t '=0:' '#{pane_id}') +r rename-window -t "$P0" edit +P1=$(r split-window -h -t "$P0" -c "$W/two" -P -F '#{pane_id}') +r split-window -v -t "$P1" -c "$W/three" +r resize-pane -t "$P0" -x 30 +r new-window -d -t '=0:5' -c "$W/two" +r split-window -d -t '=0:5' -c "$W/three" +r resize-pane -Z -t '=0:5.1' +r select-window -t '=0:edit' +r new-session -d -s work -n grid -c "$W/three" -x 100 -y 30 +for d in one two three; do r split-window -d -t '=work:grid' -c "$W/$d"; r select-layout -t '=work:grid' tiled; done +r select-pane -t '=work:grid.2' +r new-window -d -t '=work:7' -n logs -c "$W/one" +r select-window -t '=work:7' +r new-session -d -s keep -n saved -c "$W/one" +r new-window -d -t '=keep:' -n saved2 +fingerprint() { + r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{window_active}|#{window_zoomed_flag}|#{automatic-rename}|#{pane_index}|#{pane_active}|#{pane_left},#{pane_top},#{pane_width}x#{pane_height}|#{pane_current_path}' \ + | grep -E '^(0|work)\|' +} +BEFORE=$(fingerprint) +renv "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 & +DAEMON2_PID=$! +latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; } +expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80 +r kill-server +expect "the dead server's layout becomes the restore candidate" \ + "python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80 +expect "the last snapshot is kept while tmux is down" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\"" 1 +start_viewer +r new-session -d -s keep -n mine "sleep 100000" +expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}' | tr '\n' ' '" "1 " +expect "the candidate survives the new server's snapshots" \ + "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80 +expect "dry run plans: replace 0, keep keep and viewer, create work" \ + "renv \"$DINGGER\" restore --dry-run | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;" +renv "$DINGGER" restore >"$DIR/restore.out" 2>&1 +expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0 +expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE" +expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0 +expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine +expect "the candidate is marked restored" \ + "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $STATE2/restore.json" restored +r resize-window -t '=viewer:' -x 100 -y 30 +expect "restored windows follow the terminal's size again" "r display -p -t '=0:edit' '#{window_width}'" 100 +r resize-window -t '=viewer:' -x 120 -y 40 +expect "and back" "r display -p -t '=0:edit' '#{window_width}'" 120 +renv "$DINGGER" restore >"$DIR/restore2.out" 2>&1 +expect "restoring again changes nothing" "fingerprint" "$BEFORE" +expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0 +[ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; } + echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; } |
