aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/restore.rs
diff options
context:
space:
mode:
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"));