diff options
Diffstat (limited to 'crates/dinggerd/src/restore.rs')
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 243 |
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")); |
