aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/hooks.rs9
-rw-r--r--crates/dinggerd/src/main.rs27
-rw-r--r--crates/dinggerd/src/restore.rs342
-rw-r--r--crates/dinggerd/src/snapshot.rs412
-rw-r--r--crates/dinggerd/src/tmux.rs10
-rw-r--r--crates/dinggerd/src/watcher.rs4
6 files changed, 796 insertions, 8 deletions
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));
}