diff options
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 634 |
1 files changed, 389 insertions, 245 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index f1cb90e..2455407 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -1,21 +1,28 @@ //! Poll-and-detect loop: tmux pane capture → detection engine → herdr's //! status model → notifications and pane-option write-back. //! -//! Status model (herdr's, verbatim): five display statuses over two inputs — +//! Status model (herdr's, verbatim): five display statuses over two inputs, //! the raw agent state (working/blocked/idle, from hooks or scraping) and a -//! `seen` flag (attended since the last state change): +//! `seen` flag: //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| -//! | working | — | working | 2 | -//! | blocked | — | blocked | 4 | +//! | working | any | working | 2 | +//! | blocked | any | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | -//! | none | — | unknown | 0 | +//! | none | any | unknown | 0 | //! -//! "Seen" is maintained three ways: the pane is fully focused in an attached -//! client (the daemon watches tmux focus), the user jumped to it via -//! `dingger focus` / menu click, or its next transition. +//! `seen` follows herdr's `apply_pane_state_change` and +//! `mark_active_tab_seen` (src/app/actions.rs); see [`advance`]. +//! +//! All per-pane state lives in tmux pane options, so a daemon restart picks +//! up exactly where it left off: +//! +//! - `@agent_state` display status (what every surface renders) +//! - `@dingger_since` unix ms the pane started needing the user (FIFO) +//! - `@dingger_hook` last state an agent extension reported +//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` use std::collections::HashMap; @@ -26,8 +33,8 @@ use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; -/// herdr's display statuses, in priority order (highest first). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +/// herdr's display statuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Unknown, Idle, @@ -37,8 +44,7 @@ pub enum Status { } impl Status { - #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model - pub fn label(&self) -> &'static str { + pub fn label(self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", @@ -48,21 +54,18 @@ impl Status { } } - #[allow(dead_code)] // herdr parity - pub fn parse(label: &str) -> Option<Status> { + pub fn from_label(label: &str) -> Status { match label { - "working" => Some(Status::Working), - "blocked" => Some(Status::Blocked), - "done" => Some(Status::Done), - "idle" => Some(Status::Idle), - "unknown" => Some(Status::Unknown), - _ => None, + "idle" => Status::Idle, + "working" => Status::Working, + "done" => Status::Done, + "blocked" => Status::Blocked, + _ => Status::Unknown, } } /// herdr's priority: blocked > done > working > idle > unknown. - #[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon - pub fn priority(&self) -> u8 { + pub fn priority(self) -> u8 { match self { Status::Blocked => 4, Status::Done => 3, @@ -84,49 +87,126 @@ pub fn notification_kind_for_transition( } match new_state { AgentState::Blocked => Some(NotificationKind::NeedsAttention), - AgentState::Idle - if matches!(prev_state, AgentState::Working | AgentState::Blocked) => - { + AgentState::Idle if matches!(prev_state, AgentState::Working | AgentState::Blocked) => { Some(NotificationKind::Finished) } _ => None, } } +/// herdr's per-pane attendance record. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PaneTrack { - state: AgentState, - seen: bool, - /// Unix second the pane entered its current done/blocked episode; drives - /// the FIFO ready queue. - ready_since: Option<u64>, +pub struct PaneTrack { + pub state: AgentState, + pub seen: bool, + /// Unix millisecond the pane started needing the user (done or blocked); + /// orders the FIFO queue within a status. + pub since: Option<u64>, +} + +impl PaneTrack { + /// A pane not observed yet. herdr: `PaneState::new` starts `seen = true`. + pub const NEW: PaneTrack = PaneTrack { + state: AgentState::Unknown, + seen: true, + since: None, + }; + + pub fn status(&self) -> Status { + match (self.state, self.seen) { + (AgentState::Working, _) => Status::Working, + (AgentState::Blocked, _) => Status::Blocked, + (AgentState::Idle, false) => Status::Done, + (AgentState::Idle, true) => Status::Idle, + (AgentState::Unknown, _) => Status::Unknown, + } + } + + /// Rebuild a track from the pane options a previous daemon wrote. + fn restore(written_state: &str, written_since: &str) -> PaneTrack { + let since = written_since.trim().parse::<u64>().ok(); + let (state, seen) = match written_state { + "done" => (AgentState::Idle, false), + "idle" => (AgentState::Idle, true), + "working" => (AgentState::Working, true), + "blocked" => (AgentState::Blocked, true), + _ => return PaneTrack::NEW, + }; + let track = PaneTrack { state, seen, since }; + let needs_user = matches!(track.status(), Status::Done | Status::Blocked); + PaneTrack { + since: if needs_user { since } else { None }, + ..track + } + } +} + +/// One observation step for a pane: herdr's `apply_pane_state_change` +/// followed by `mark_active_tab_seen`, as a pure function. +/// +/// - Transition to any non-idle state: seen. +/// - Completion (working/blocked to idle): seen only if the user is looking +/// at the pane's window; otherwise the pane is done. +/// - Any other transition to idle (first observation): seen unchanged. +/// - Looking at the pane's window, or jumping to it (`acked`): seen. +pub fn advance( + prev: PaneTrack, + observed: AgentState, + attended: bool, + acked: bool, + now: u64, +) -> PaneTrack { + let mut next = prev; + if observed != prev.state { + next.state = observed; + if observed != AgentState::Idle { + next.seen = true; + } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { + next.seen = attended; + } + next.since = None; // a new episode + } + if attended || acked { + next.seen = true; + } + let needs_user = matches!(next.status(), Status::Done | Status::Blocked); + next.since = match (needs_user, next.since) { + (true, Some(since)) => Some(since), + (true, None) => Some(now), + (false, _) => None, + }; + next } pub struct Watcher { tracks: HashMap<String, PaneTrack>, - /// Last published (queue, counts); avoids rewriting options every tick. - last_published: Option<(String, (String, String, String, String))>, + /// Last published (queue, blocked, done, working); avoids rewriting the + /// global options every tick. + last_published: Option<(String, usize, usize, usize)>, interval_secs: u64, sounds: bool, + notifications: bool, } impl Watcher { - pub fn new(interval_secs: u64, sounds: bool) -> Self { + pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), last_published: None, interval_secs: interval_secs.max(1), sounds, + notifications, } } pub fn run(&mut self) -> Result<(), String> { info!(interval = self.interval_secs, "watching tmux panes"); - // dingger is a client of the tmux server, which may not exist yet — + // dingger is a client of the tmux server, which may not exist yet: // it starts when the first terminal opens one. Wait for it, then // keep watching across server restarts. let mut had_server = false; loop { + let started = std::time::Instant::now(); match self.tick() { Ok(()) => { if !had_server { @@ -141,145 +221,64 @@ impl Watcher { had_server = false; } } - std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); + debug!(ms = started.elapsed().as_millis() as u64, "tick"); + hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } } fn tick(&mut self) -> Result<(), String> { let panes = tmux::list_panes()?; + let now = unix_now(); // Forget panes that went away. self.tracks .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); - hooks::forget( - &panes - .iter() - .map(|pane| pane.pane_id.clone()) - .collect::<Vec<_>>(), - ); + hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>()); for pane in &panes { - let prev_track = self.tracks.get(&pane.pane_id).copied(); - let (prev_state, prev_seen) = prev_track - .map(|track| (track.state, track.seen)) - .unwrap_or((AgentState::Unknown, true)); - - // Raw state: hook reports are authoritative while the pane runs - // the agent; screen detection is the fallback for panes that - // never report. Viewer screens (transcripts, pickers) return - // `skip_state_update: true` — they say nothing about state and - // leave the previous one standing (herdr's publishing contract). - let raw = if pane.dead { - Some(AgentState::Idle) - } else if let Some(agent) = pane.agent { - if let Some(state) = hooks::hook_state(&pane.pane_id) { - Some(state) - } else { - let screen = match tmux::capture_pane(&pane.pane_id) { - Ok(screen) => screen, - Err(err) => { - warn!(target = %pane.display_target, %err, "capture failed"); - continue; - } - }; - // tmux pane_title carries the last OSC title the application - // set; tmux does not re-expose OSC progress sequences. - let detection = - detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); - (!detection.skip_state_update).then_some(detection.state) - } - } else { - // A pane that stops running an agent (agent quit into a - // shell) is no longer an observation; a stale hook report - // from the dead agent is not authoritative. - Some(AgentState::Unknown) - }; - - let Some(new_state) = raw else { + let Some(observations) = observe(pane) else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; - // Seen: attended since the last state change. Attended means the - // pane is fully focused in an attached client, the user jumped to - // it (`@dingger_ack`), or it was just first observed. A first - // observation of idle is seen (nothing has happened yet); a - // working/blocked → idle transition is an unseen completion. - let transition = new_state != prev_state; - // Attended also via `dingger focus` / menu click (@dingger_ack). - let mut seen = if transition { - !(new_state == AgentState::Idle && prev_state == AgentState::Unknown) - } else { - prev_seen || pane.written_ack == "1" - }; - if !seen && pane.attended() { - seen = true; - } - if transition || seen != prev_seen { - let ready_since = if is_ready_to_attend(new_state, seen) { - Some(unix_now()) - } else { - None - }; - self.tracks.insert( - pane.pane_id.clone(), - PaneTrack { - state: new_state, - seen, - ready_since, - }, - ); - } - if transition { - debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change"); - let _ = tmux::clear_ack(&pane.pane_id); - } else if seen != prev_seen { - let _ = tmux::ack(&pane.pane_id); - } - let display = display_status( - self.tracks - .get(&pane.pane_id) - .unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }), - ); - // @agent_state mirrors the display status on both transitions and - // attendance changes (done → idle when the user focuses the pane). - if pane.written_state != display.label() { - if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) { - warn!(target = %pane.display_target, %err, "failed to write @agent_state"); - } - } - if new_state == AgentState::Unknown { - // The agent is gone; its last hook report must not outlive it. - hooks::drop_report(&pane.pane_id); - } + // First sight of a pane in this process: resume from its options. + let restored = !self.tracks.contains_key(&pane.pane_id); + let mut track = self + .tracks + .get(&pane.pane_id) + .copied() + .unwrap_or_else(|| PaneTrack::restore(&pane.written_state, &pane.written_since)); + let acked = pane.written_ack == "1"; - // Keep the pane's agent label current even when the state is - // unchanged (e.g. an agent swap between two idle panes). - let agent_label = pane - .agent - .map(detect_core::agent_label) - .unwrap_or("") - .to_string(); - if pane.written_agent != agent_label { - if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { - warn!(target = %pane.display_target, %err, "failed to write @agent"); + // Apply every observation in order: a turn that started and + // finished between two ticks is still a completion. + let mut alerts = Vec::new(); + for observed in observations { + let next = advance(track, observed, pane.attended(), acked, now); + if next.state != track.state { + info!( + target = %pane.display_target, + prev = track.status().label(), + next = next.status().label(), + "state change" + ); + // Notify on transitions observed by this process, not + // when resuming from options written before a restart. + if !restored { + alerts.extend(notification_kind_for_transition(track.state, next.state)); + } } + track = next; } + self.tracks.insert(pane.pane_id.clone(), track); - if transition { - if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { - // herdr: a blocked pane plays the alert sound even when - // the user is watching; a finished pane stays silent when - // they are already looking at it. - let suppressed = pane.suppresses_notifications(); - if !suppressed { - let context = format!("{} ({})", pane.display_target, agent_label); - send_notification(&agent_label, &kind, &context); - } - if self.sounds { - play_sound(&kind); - } - } + // Update what the user sees first, then alert. + if acked { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); + } + self.write_pane_options(pane, track); + for kind in alerts { + self.notify(pane, kind); } } @@ -287,134 +286,279 @@ impl Watcher { Ok(()) } - /// Publish the FIFO queue (done + blocked panes, oldest transition first) - /// and counts as server-global options for the tmux status line and - /// `dingger focus`. + fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { + let label = track.status().label(); + if pane.written_state != label { + if let Err(err) = tmux::set_pane_option(&pane.pane_id, "@agent_state", label) { + warn!(target = %pane.display_target, %err, "failed to write @agent_state"); + } + } + let since = track.since.map(|s| s.to_string()).unwrap_or_default(); + if pane.written_since != since { + let _ = if since.is_empty() { + tmux::unset_pane_option(&pane.pane_id, "@dingger_since") + } else { + tmux::set_pane_option(&pane.pane_id, "@dingger_since", &since) + }; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or(""); + if pane.written_agent != agent { + let _ = tmux::set_pane_option(&pane.pane_id, "@agent", agent); + } + // Persist the authoritative hook state so a restart keeps it. + let hook = hooks::last_state(&pane.pane_id) + .map(state_label) + .unwrap_or_default(); + if pane.agent.is_some() && !hook.is_empty() && pane.written_hook != hook { + let _ = tmux::set_pane_option(&pane.pane_id, "@dingger_hook", hook); + } + if pane.agent.is_none() && !pane.written_hook.is_empty() { + let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_hook"); + } + } + + fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { + // herdr: a blocked pane plays the alert even when the user is looking + // at it; a finished pane stays silent when they already are. + let watching = pane.attended(); + if matches!(kind, NotificationKind::Finished) && watching { + return; + } + let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); + if self.notifications && !watching { + send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); + } + if self.sounds { + play_sound(&kind); + } + } + + /// Publish the ready queue and counts as server-global options for the + /// status line, menu bar, and focus. The queue is ordered like the menu: + /// herdr's priority (blocked before done), oldest first within it. fn publish(&mut self, panes: &[tmux::Pane]) { - let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since) - let mut blocked = 0usize; - let mut done = 0usize; - let mut working = 0usize; + let mut queue: Vec<(std::cmp::Reverse<u8>, u64, &str)> = Vec::new(); + let (mut blocked, mut done, mut working) = (0, 0, 0); for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; - let status = display_status(track); - match status { + match track.status() { + Status::Blocked => blocked += 1, + Status::Done => done += 1, Status::Working => working += 1, - Status::Done | Status::Blocked => { - if track.ready_since.is_some() { - if status == Status::Blocked { - blocked += 1; - } else { - done += 1; - } - if let Some(since) = track.ready_since { - ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since)); - } - } - } Status::Idle | Status::Unknown => {} } + if let Some(since) = track.since { + queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); + } } - ready.sort_by_key(|(_, _, since)| *since); - - let queue = ready + queue.sort(); + let queue = queue .iter() - .map(|(pane_id, _, since)| format!("{pane_id}:{since}")) + .map(|(_, _, pane_id)| *pane_id) .collect::<Vec<_>>() .join(","); - let counts = ( - ready.len().to_string(), - blocked.to_string(), - working.to_string(), - done.to_string(), - ); - let next = (queue.clone(), counts.clone()); - if self.last_published.as_ref() != Some(&next) { - self.last_published = Some(next); - for (name, value) in [ - ("@dingger_queue", queue.as_str()), - ("@dingger_ready", counts.0.as_str()), - ("@dingger_blocked", counts.1.as_str()), - ("@dingger_working", counts.2.as_str()), - ("@dingger_done", counts.3.as_str()), - ] { - if let Err(err) = tmux::set_global(name, value) { - warn!(%err, "failed to write {name}"); - } + + let next = (queue, blocked, done, working); + if self.last_published.as_ref() == Some(&next) { + return; + } + for (name, value) in [ + ("@dingger_queue", next.0.clone()), + ("@dingger_blocked", next.1.to_string()), + ("@dingger_done", next.2.to_string()), + ("@dingger_working", next.3.to_string()), + ] { + if let Err(err) = tmux::set_global(name, &value) { + warn!(%err, "failed to write {name}"); } } + self.last_published = Some(next); + } +} + +/// The pane's raw agent states this tick, in order, or `None` when the +/// screen is a viewer (transcript, picker) that says nothing about state. +fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> { + if pane.dead { + return Some(vec![AgentState::Idle]); + } + let Some(agent) = pane.agent else { + // The agent is gone; its last hook report must not outlive it. + hooks::drop_reports(&pane.pane_id); + return Some(vec![AgentState::Unknown]); + }; + // Agent-reported state is authoritative; it survives restarts through + // the `@dingger_hook` option until the extension reports again. + if let Some(states) = hooks::take_observations(&pane.pane_id) { + return Some(states); + } + if let Some(state) = parse_state(&pane.written_hook) { + return Some(vec![state]); } + let screen = match tmux::capture_pane(&pane.pane_id) { + Ok(screen) => screen, + Err(err) => { + warn!(target = %pane.display_target, %err, "capture failed"); + return None; + } + }; + // tmux pane_title carries the last OSC title the application set; tmux + // does not re-expose OSC progress sequences. + let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); + (!detection.skip_state_update).then(|| vec![detection.state]) } -/// When a pane became ready for the user, as unix seconds. Ready means the -/// agent is waiting on the user: done (finished, unseen) or blocked. -fn is_ready_to_attend(state: AgentState, seen: bool) -> bool { +fn state_label(state: AgentState) -> &'static str { match state { - AgentState::Blocked => true, - AgentState::Idle => !seen, - _ => false, + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", } } -fn display_status(track: &PaneTrack) -> Status { - match track.state { - AgentState::Working => Status::Working, - AgentState::Blocked => Status::Blocked, - AgentState::Idle => { - if track.seen { - Status::Idle - } else { - Status::Done - } - } - AgentState::Unknown => Status::Unknown, +fn parse_state(label: &str) -> Option<AgentState> { + match label { + "idle" => Some(AgentState::Idle), + "working" => Some(AgentState::Working), + "blocked" => Some(AgentState::Blocked), + _ => None, } } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) + .map(|d| d.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; + use AgentState::{Blocked, Idle, Unknown, Working}; + + fn step(prev: PaneTrack, observed: AgentState, attended: bool) -> PaneTrack { + advance(prev, observed, attended, false, 100) + } + + #[test] + fn notification_policy_matches_herdr() { + assert!(matches!( + notification_kind_for_transition(Working, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Unknown, Blocked), + Some(NotificationKind::NeedsAttention) + )); + assert!(matches!( + notification_kind_for_transition(Working, Idle), + Some(NotificationKind::Finished) + )); + assert!(matches!( + notification_kind_for_transition(Blocked, Idle), + Some(NotificationKind::Finished) + )); + assert!(notification_kind_for_transition(Blocked, Blocked).is_none()); + assert!(notification_kind_for_transition(Unknown, Idle).is_none()); + assert!(notification_kind_for_transition(Idle, Working).is_none()); + assert!(notification_kind_for_transition(Idle, Unknown).is_none()); + } + + // herdr: initial_idle_in_background_stays_seen + #[test] + fn first_observation_of_idle_is_idle() { + let track = step(PaneTrack::NEW, Idle, false); + assert_eq!(track.status(), Status::Idle); + assert_eq!(track.since, None); + } + + // herdr: state_changed_idle_in_background_marks_unseen + #[test] + fn completion_away_from_the_window_is_done() { + let working = step(step(PaneTrack::NEW, Idle, false), Working, false); + assert_eq!(working.status(), Status::Working); + let done = step(working, Idle, false); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(100)); + } + + // herdr: active_tab_completion_marks_pane_seen + #[test] + fn completion_while_looking_is_idle() { + let working = step(PaneTrack::NEW, Working, true); + let idle = step(working, Idle, true); + assert_eq!(idle.status(), Status::Idle); + assert_eq!(idle.since, None); + } + + // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen) + #[test] + fn looking_at_the_window_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + let still_done = step(done, Idle, false); + assert_eq!(still_done.status(), Status::Done); + let seen = step(still_done, Idle, true); + assert_eq!(seen.status(), Status::Idle); + assert_eq!(seen.since, None); + } + + // herdr: agent_focus_marks_already_focused_done_agent_seen + #[test] + fn jumping_to_a_pane_turns_done_into_idle() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let seen = advance(done, Idle, false, true, 200); + assert_eq!(seen.status(), Status::Idle); + } + + #[test] + fn working_again_clears_done_and_the_next_completion_is_done_again() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + let working = step(done, Working, false); + assert_eq!(working.status(), Status::Working); + assert_eq!(working.since, None); + let done_again = advance(working, Idle, false, false, 300); + assert_eq!(done_again.status(), Status::Done); + assert_eq!(done_again.since, Some(300)); + } + + #[test] + fn blocked_needs_the_user_even_while_looking() { + let blocked = step(step(PaneTrack::NEW, Working, false), Blocked, true); + assert_eq!(blocked.status(), Status::Blocked); + assert_eq!(blocked.since, Some(100)); + // answering the prompt: back to work, then a completion away → done + let done = step(step(blocked, Working, false), Idle, false); + assert_eq!(done.status(), Status::Done); + } + + #[test] + fn fifo_stamp_survives_unchanged_ticks() { + let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); + let later = advance(done, Idle, false, false, 999); + assert_eq!(later.since, Some(50)); + } #[test] - fn policy_matches_herdr() { - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none()); - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked) - .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); - assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle) - .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); - // Unknown → Idle is a first observation, not a completion. - assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); - assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); + fn agent_exit_is_unknown() { + let done = step(step(PaneTrack::NEW, Working, false), Idle, false); + assert_eq!(step(done, Unknown, false).status(), Status::Unknown); } #[test] - fn display_status_maps_herdr() { - let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) }; - let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None }; - assert_eq!(display_status(&unseen), Status::Done); - assert_eq!(display_status(&seen), Status::Idle); - assert_eq!( - display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }), - Status::Working - ); - assert_eq!( - display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }), - Status::Blocked - ); + fn restore_resumes_where_the_previous_daemon_left_off() { + let done = PaneTrack::restore("done", "42"); + assert_eq!(done.status(), Status::Done); + assert_eq!(done.since, Some(42)); + // the same observation after a restart keeps it done + assert_eq!(step(done, Idle, false).status(), Status::Done); + assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); + assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); + assert_eq!(PaneTrack::restore("idle", "42").since, None); } #[test] |
