aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/restore.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 13:59:59 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 13:59:59 +0300
commit9c9c9dad93b16781ec2fc40bd7582db62240171c (patch)
treecf85f3a53f8441eae36982d06244041cfe0ff6f3 /crates/dinggerd/src/restore.rs
parentc082f5044eedd2294d72f920d1018e40d73aab33 (diff)
restore, step 3: offer it in the panel, menu, and status line
when tmux stops (and its sessions are missing from the running server, or no server runs), the daemon publishes the restore offer: offer.json for the menu bar app and @dingger_restore for the status line, withdrawn once restored or dismissed. - panel: a first row "↺ Restore 9 sessions · 22 agents" with when it was saved and how many agents were mid-turn; clicking restores, "Not now" dismisses. an offer shows the panel like a waiting agent; off stays off. - menu: the offer on top; restore all, one session, or not now. - status line: a clickable blue ↺N before the clock. - restore runs through the user's login shell, as a terminal starts tmux, so restored panes get a real environment, not launchd's; --report writes restore.log and notifies a summary. restore --dismiss. - commands log only warnings by default; SIGPIPE ends them quietly. e2e: the offer through the real menu bar binary, the summary notification, the offer clearing, a restore with no tmux running, and not now staying dismissed when a terminal starts tmux. the second test daemon is started directly (it leaked through a function's $! before, and dozens of leaked daemons fought over the test socket and loaded the machine); each run uses its own socket and checks its daemons stop.
Diffstat (limited to 'crates/dinggerd/src/restore.rs')
-rw-r--r--crates/dinggerd/src/restore.rs243
1 files changed, 223 insertions, 20 deletions
diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs
index 2332865..623cdcd 100644
--- a/crates/dinggerd/src/restore.rs
+++ b/crates/dinggerd/src/restore.rs
@@ -18,6 +18,27 @@ use std::time::{Duration, Instant};
use crate::snapshot::{self, shell_quote, PaneSnap, SessionSnap, Snapshot, WindowSnap};
use crate::tmux;
+thread_local! {
+ static REPORT: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
+}
+
+/// Print a report line, and keep it for `--report`.
+fn say(line: String) {
+ println!("{line}");
+ REPORT.with(|report| report.borrow_mut().push(line));
+}
+
+/// What a restore did, for the summary notification.
+#[derive(Debug, Default)]
+pub struct Summary {
+ pub sessions: usize,
+ pub failed: usize,
+ pub agents: usize,
+ pub resumed: usize,
+ pub interrupted: usize,
+ pub typed: usize,
+}
+
/// What restore types into a rebuilt pane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Typed {
@@ -239,7 +260,7 @@ fn replace_session(session: &SessionSnap, rebuilt: &mut Vec<Rebuilt>) -> Result<
/// Wait for each new shell's prompt, then resume agents (spaced out) and
/// type the other command lines without running them; report which agents
/// did not start.
-fn type_commands(rebuilt: &[Rebuilt]) {
+fn type_commands(rebuilt: &[Rebuilt], summary: &mut Summary) {
let work: Vec<(&Rebuilt, Typed)> =
rebuilt.iter().filter_map(|r| typed_for(&r.pane).map(|typed| (r, typed))).collect();
if work.is_empty() {
@@ -266,21 +287,23 @@ fn type_commands(rebuilt: &[Rebuilt]) {
Typed::Resume(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "Enter"]);
- println!("resume {} {command}", r.label);
+ say(format!("resume {} {command}", r.label));
resumed.push(*r);
// Agents start one at a time.
std::thread::sleep(Duration::from_millis(500));
}
Typed::Retype(command) => {
let _ = tmux::run(&["send-keys", "-t", &r.pane_id, "-l", command]);
- println!("type {} {command} (press Enter to run)", r.label);
+ say(format!("type {} {command} (press Enter to run)", r.label));
+ summary.typed += 1;
}
}
}
for (r, _) in &work {
if let Some(agent) = r.pane.agent.as_ref().filter(|agent| agent.state == "working") {
let name = if agent.name.is_empty() { &agent.agent } else { &agent.name };
- println!("interrupted {} {name}: it was working, its turn was cut short", r.label);
+ say(format!("interrupted {} {name}: it was working, its turn was cut short", r.label));
+ summary.interrupted += 1;
}
}
if resumed.is_empty() {
@@ -297,9 +320,11 @@ fn type_commands(rebuilt: &[Rebuilt]) {
.unwrap_or(true)
});
}
- println!("resumed {} of {} agents", resumed.len() - not_running.len(), resumed.len());
+ summary.agents = resumed.len();
+ summary.resumed = resumed.len() - not_running.len();
+ say(format!("resumed {} of {} agents", summary.resumed, summary.agents));
for r in not_running {
- println!("not running {}: check the pane for the error", r.label);
+ say(format!("not running {}: check the pane for the error", r.label));
}
}
@@ -348,9 +373,59 @@ fn format_time(ms: u64) -> 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> {
+/// Restore missing sessions (all, or only `only`). With `report`, also
+/// write the report to `restore.log` and notify a summary.
+pub fn run(from: Option<&str>, only: &[String], dry_run: bool, report: bool) -> Result<(), String> {
+ let result = restore(from, only, dry_run);
+ if report && !dry_run {
+ let lines = REPORT.with(|report| report.borrow().join("\n"));
+ let _ = std::fs::write(snapshot::state_dir().join("restore.log"), format!("{lines}\n"));
+ let (title, body) = match &result {
+ Ok(summary) => summary_message(summary),
+ Err(err) => ("tmux restore failed".to_string(), err.clone()),
+ };
+ crate::notify::send_message_blocking(&title, &body);
+ }
+ let _ = crate::hooks::poke();
+ result.map(|_| ())
+}
+
+fn plural(n: usize, one: &str, many: &str) -> String {
+ format!("{n} {}", if n == 1 { one } else { many })
+}
+
+pub fn summary_message(summary: &Summary) -> (String, String) {
+ let title = format!("Restored {}", plural(summary.sessions, "tmux session", "tmux sessions"));
+ let mut parts = Vec::new();
+ if summary.agents > 0 {
+ parts.push(format!("resumed {} of {}", summary.resumed, plural(summary.agents, "agent", "agents")));
+ }
+ if summary.interrupted > 0 {
+ parts.push(format!("{} mid-turn", plural(summary.interrupted, "was", "were")));
+ }
+ if summary.typed > 0 {
+ parts.push(format!("{} typed, not run", plural(summary.typed, "command", "commands")));
+ }
+ if summary.failed > 0 {
+ parts.push(format!("{} failed", plural(summary.failed, "session", "sessions")));
+ }
+ if parts.is_empty() {
+ parts.push("nothing else to resume".into());
+ }
+ (title, parts.join(" · "))
+}
+
+/// Mark the restore candidate dismissed: no more offers for it.
+pub fn dismiss() -> Result<(), String> {
+ snapshot::set_candidate_status("dismissed")?;
+ let _ = crate::hooks::poke();
+ println!("dismissed; `dingger restore` still restores it");
+ Ok(())
+}
+
+fn restore(from: Option<&str>, only: &[String], dry_run: bool) -> Result<Summary, String> {
let snapshot = source(from)?;
+ let mut summary = Summary::default();
let existing = existing_sessions();
let mut failures = 0;
let mut rebuilt = Vec::new();
@@ -364,19 +439,19 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
let what = windows_and_panes(session);
let verb = match plan {
Plan::Keep => {
- println!("keep {name}: it exists");
+ say(format!("keep {name}: it exists"));
continue;
}
Plan::Create => "create",
Plan::Replace => "replace",
};
if dry_run {
- println!("{verb:<8} {name}: {what}");
+ say(format!("{verb:<8} {name}: {what}"));
for window in &session.windows {
for pane in &window.panes {
match typed_for(pane) {
- Some(Typed::Resume(command)) => println!(" resume {} {command}", window.name),
- Some(Typed::Retype(command)) => println!(" type {} {command}", window.name),
+ Some(Typed::Resume(command)) => say(format!(" resume {} {command}", window.name)),
+ Some(Typed::Retype(command)) => say(format!(" type {} {command}", window.name)),
None => {}
}
}
@@ -388,23 +463,124 @@ pub fn run(from: Option<&str>, only: &[String], dry_run: bool) -> Result<(), Str
_ => build_session(session, &name, &mut rebuilt).map(|_| ()),
};
match result {
- Ok(()) => println!("{verb:<8} {name}: {what}"),
+ Ok(()) => {
+ summary.sessions += 1;
+ say(format!("{verb:<8} {name}: {what}"));
+ }
Err(err) => {
failures += 1;
- println!("FAILED {name}: {err}");
+ say(format!("FAILED {name}: {err}"));
}
}
}
if !rebuilt.is_empty() {
- type_commands(&rebuilt);
+ type_commands(&rebuilt, &mut summary);
}
- if dry_run || from.is_some() {
- return if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) };
- }
- if missing(&snapshot, &existing_sessions()).is_empty() {
+ summary.failed = failures;
+ if !dry_run && from.is_none() && missing(&snapshot, &existing_sessions()).is_empty() {
let _ = snapshot::set_candidate_status("restored");
}
- if failures == 0 { Ok(()) } else { Err(format!("{failures} sessions failed")) }
+ if failures == 0 { Ok(summary) } else { Err(format!("{failures} sessions failed")) }
+}
+
+/// One session restore would bring back, as offered to the user.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct OfferedSession {
+ pub name: String,
+ pub windows: usize,
+ pub panes: usize,
+ pub agents: usize,
+}
+
+/// The restore offer the menu bar app shows (`offer.json`).
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct Offer {
+ pub saved_at: u64,
+ pub sessions: Vec<OfferedSession>,
+ pub agents: usize,
+ /// Agents that were mid-turn when tmux stopped.
+ pub interrupted: usize,
+}
+
+pub fn offer(snapshot: &Snapshot, existing: &[Existing]) -> Option<Offer> {
+ let missing = missing(snapshot, existing);
+ let sessions: Vec<&SessionSnap> =
+ snapshot.sessions.iter().filter(|session| missing.contains(&session.name)).collect();
+ if sessions.is_empty() {
+ return None;
+ }
+ let panes = |session: &&SessionSnap| session.windows.iter().flat_map(|w| &w.panes).cloned().collect::<Vec<_>>();
+ let agents = sessions.iter().flat_map(panes).filter(|pane| pane.agent.is_some()).count();
+ let interrupted = sessions
+ .iter()
+ .flat_map(panes)
+ .filter(|pane| pane.agent.as_ref().is_some_and(|agent| agent.state == "working"))
+ .count();
+ Some(Offer {
+ saved_at: snapshot.saved_at,
+ sessions: sessions
+ .iter()
+ .map(|session| OfferedSession {
+ name: session.name.clone(),
+ windows: session.windows.len(),
+ panes: session.windows.iter().map(|w| w.panes.len()).sum(),
+ agents: panes(session).iter().filter(|pane| pane.agent.is_some()).count(),
+ })
+ .collect(),
+ agents,
+ interrupted,
+ })
+}
+
+/// Keeps `offer.json` and the `@dingger_restore` option current; called by
+/// the watcher every loop.
+#[derive(Default)]
+pub struct OfferPublisher {
+ candidate: Option<(std::time::SystemTime, Option<snapshot::Candidate>)>,
+ published: Option<Option<Offer>>,
+}
+
+impl OfferPublisher {
+ pub fn tick(&mut self) {
+ let path = snapshot::state_dir().join("restore.json");
+ let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
+ let stale = match (&self.candidate, modified) {
+ (Some((at, _)), Some(modified)) => *at != modified,
+ (None, _) => true,
+ (Some(_), None) => true,
+ };
+ if stale {
+ self.candidate = Some((modified.unwrap_or(std::time::UNIX_EPOCH), snapshot::candidate()));
+ }
+ let offered = self
+ .candidate
+ .as_ref()
+ .and_then(|(_, candidate)| candidate.as_ref())
+ .filter(|candidate| candidate.status == "offered");
+ let offer = offered.and_then(|candidate| offer(&candidate.snapshot, &existing_sessions()));
+ if self.published.as_ref() == Some(&offer) {
+ return;
+ }
+ let file = snapshot::state_dir().join("offer.json");
+ match &offer {
+ Some(offer) => {
+ if let Ok(json) = serde_json::to_string_pretty(offer) {
+ let tmp = file.with_extension("tmp");
+ if std::fs::write(&tmp, json).is_ok() {
+ let _ = std::fs::rename(&tmp, &file);
+ }
+ }
+ let set = tmux::run(&["set-option", "-g", "@dingger_restore", &offer.sessions.len().to_string()]);
+ tracing::info!(sessions = offer.sessions.len(), agents = offer.agents, ok = set.is_ok(), "restore offered");
+ }
+ None => {
+ let _ = std::fs::remove_file(&file);
+ let unset = tmux::run(&["set-option", "-g", "-u", "@dingger_restore"]);
+ tracing::info!(?unset, "restore offer withdrawn");
+ }
+ }
+ self.published = Some(offer);
+ }
}
#[cfg(test)]
@@ -494,6 +670,33 @@ mod tests {
}
#[test]
+ fn offers_only_missing_sessions_with_their_agents() {
+ let mut saved = snapshot(&["0", "work"]);
+ saved.sessions[1].windows[0].panes[0].agent = Some(AgentSnap {
+ agent: "pi".into(),
+ name: "x".into(),
+ state: "working".into(),
+ session: None,
+ });
+ let offer = offer(&saved, &[Existing { name: "0".into(), bare: false }]).expect("work is missing");
+ assert_eq!(offer.sessions.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["work"]);
+ assert_eq!((offer.agents, offer.interrupted, offer.sessions[0].panes), (1, 1, 2));
+ assert_eq!(super::offer(&saved, &[
+ Existing { name: "0".into(), bare: false },
+ Existing { name: "work".into(), bare: false },
+ ]), None, "nothing missing, no offer");
+ }
+
+ #[test]
+ fn summarizes_a_restore() {
+ let summary = Summary { sessions: 9, agents: 22, resumed: 21, interrupted: 1, typed: 3, failed: 0 };
+ assert_eq!(
+ summary_message(&summary),
+ ("Restored 9 tmux sessions".into(), "resumed 21 of 22 agents · 1 was mid-turn · 3 commands typed, not run".into())
+ );
+ }
+
+ #[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"));