diff options
Diffstat (limited to 'crates/dinggerd/src/snapshot.rs')
| -rw-r--r-- | crates/dinggerd/src/snapshot.rs | 412 |
1 files changed, 412 insertions, 0 deletions
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"); + } +} |
