aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/snapshot.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/snapshot.rs')
-rw-r--r--crates/dinggerd/src/snapshot.rs412
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");
+ }
+}