Skip to main content

git_paw/coordination/
inventory.rs

1//! Agent inventory + target validation for the supervisor `/agents` and
2//! `/tell` routing commands.
3//!
4//! The inventory is composed from two sources (design D1):
5//! - broker `GET /status` — `branch_id`, `status`, `last_seen`, `cli`;
6//! - `tmux list-panes` with `pane_current_path` — the live
7//!   `branch_id → pane_index` mapping (v0.5.0 doctrine: never assume pane
8//!   index ordering; resolve via the worktree path).
9//!
10//! The join, mode detection, target validation, and the freshness cache are
11//! all factored as library functions (design D6) so the v0.6.0 consumer (the
12//! `/tell` skill) and future consumers — notably the v1.0.0 MCP write tools'
13//! `publish_agent_feedback` — share one inventory + validation shape rather
14//! than re-implementing it.
15
16use std::collections::HashMap;
17use std::fmt;
18use std::io::{Read, Write};
19use std::net::TcpStream;
20use std::time::{Duration, Instant};
21
22use serde::Deserialize;
23
24use crate::error::PawError;
25
26/// Best-effort detected interaction mode of an agent's CLI pane.
27///
28/// Detection is heuristic (design D1 / D3): when the pane title and recent
29/// capture give no clear signal the mode is [`Mode::Unknown`], and `/tell`
30/// treats `unknown` (and `interactive`) as requiring the safe
31/// `agent.feedback` delivery path.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Mode {
34    /// The CLI is in accept-edits / auto-accept mode — safe for `send-keys`.
35    AcceptEdits,
36    /// The CLI is in an interactive prompt-per-action mode.
37    Interactive,
38    /// No clear signal; consumers fall back to the safe delivery mode.
39    Unknown,
40}
41
42impl Mode {
43    /// The kebab-case label used in the rendered inventory and learnings.
44    #[must_use]
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::AcceptEdits => "accept-edits",
48            Self::Interactive => "interactive",
49            Self::Unknown => "unknown",
50        }
51    }
52}
53
54impl fmt::Display for Mode {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60/// One agent's inventory record.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AgentEntry {
63    /// Agent identifier as registered with the broker (slug form, e.g.
64    /// `feat-auth`). `/tell` accepts either the slug or the slash form.
65    pub branch_id: String,
66    /// Status label from the broker (`"working"`, `"done"`, `"blocked"`, …).
67    pub status: String,
68    /// Seconds since the agent was last seen, per the broker.
69    pub last_seen_seconds: u64,
70    /// CLI name running in the pane (`"claude"`, …), when known.
71    pub cli: Option<String>,
72    /// Best-effort detected interaction mode.
73    pub mode: Mode,
74    /// tmux pane index resolved via `pane_current_path`, when matched.
75    pub pane_index: Option<usize>,
76}
77
78/// A point-in-time inventory snapshot.
79#[derive(Debug, Clone)]
80pub struct AgentInventory {
81    /// One entry per agent the broker knows about, plus the supervisor row.
82    pub entries: Vec<AgentEntry>,
83    /// When this snapshot was built — used by [`InventoryCache`] freshness.
84    pub refreshed_at: Instant,
85}
86
87impl AgentInventory {
88    /// Looks up an entry by target identifier, matching either the slug
89    /// (`feat-auth`) or slash form (`feat/auth`).
90    #[must_use]
91    pub fn find(&self, target_id: &str) -> Option<&AgentEntry> {
92        let needle = normalize_id(target_id);
93        self.entries
94            .iter()
95            .find(|e| normalize_id(&e.branch_id) == needle)
96    }
97
98    /// The candidate target identifiers (every agent except the supervisor),
99    /// sorted for deterministic rendering.
100    #[must_use]
101    pub fn candidate_ids(&self) -> Vec<String> {
102        let mut ids: Vec<String> = self
103            .entries
104            .iter()
105            .filter(|e| e.branch_id != "supervisor")
106            .map(|e| e.branch_id.clone())
107            .collect();
108        ids.sort();
109        ids
110    }
111}
112
113/// Normalises an agent identifier for comparison: trims surrounding
114/// whitespace and treats the slash form (`feat/auth`) and slug form
115/// (`feat-auth`) as equivalent.
116fn normalize_id(id: &str) -> String {
117    id.trim().replace('/', "-")
118}
119
120/// One agent row parsed from the broker `/status` JSON.
121///
122/// Mirrors the public fields of `broker::AgentStatusEntry`; only the fields
123/// the inventory needs are deserialised. Kept `pub(crate)` so the join and
124/// its fixtures-based unit tests can construct rows directly.
125#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
126pub struct StatusAgent {
127    /// Agent identifier (slug form).
128    pub agent_id: String,
129    /// Status label.
130    #[serde(default)]
131    pub status: String,
132    /// Seconds since last seen.
133    #[serde(default)]
134    pub last_seen_seconds: u64,
135    /// CLI name; the `/status` endpoint emits an empty string when unknown,
136    /// which the join maps to `None`.
137    #[serde(default)]
138    pub cli: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct StatusBody {
143    #[serde(default)]
144    agents: Vec<StatusAgent>,
145}
146
147/// Parses the broker `GET /status` JSON body into agent rows.
148///
149/// # Errors
150/// Returns [`PawError::SessionError`] when the body is not valid `/status`
151/// JSON.
152pub fn parse_status_agents(json: &str) -> Result<Vec<StatusAgent>, PawError> {
153    let body: StatusBody = serde_json::from_str(json)
154        .map_err(|e| PawError::SessionError(format!("broker /status parse error: {e}")))?;
155    Ok(body.agents)
156}
157
158/// Parses `tmux list-panes -F '#{pane_index} #{pane_current_path}'` output
159/// into `(pane_index, current_path)` pairs.
160///
161/// Lines that do not start with a numeric pane index are skipped. The path is
162/// the remainder of the line (paths never contain the separating space at the
163/// front, and may themselves contain spaces).
164#[must_use]
165pub fn parse_pane_paths(output: &str) -> Vec<(usize, String)> {
166    output
167        .lines()
168        .filter_map(|line| {
169            let line = line.trim_end();
170            let (idx, path) = line.split_once(' ')?;
171            let idx: usize = idx.trim().parse().ok()?;
172            Some((idx, path.to_string()))
173        })
174        .collect()
175}
176
177/// Resolves the pane index for `agent_id` from the `pane_current_path`
178/// mapping, per the v0.5.0 doctrine (match on the worktree path, never on
179/// index ordering).
180///
181/// The supervisor occupies pane 0 by construction; every other agent's
182/// worktree basename ends in `-<agent_id>` (e.g. `myproj-feat-auth` for
183/// `feat-auth`). The `-` prefix on the suffix prevents `feat-a` from matching
184/// `…-feat-api`.
185#[must_use]
186pub fn match_pane(agent_id: &str, pane_paths: &[(usize, String)]) -> Option<usize> {
187    if agent_id == "supervisor" {
188        return Some(0);
189    }
190    let suffix = format!("-{agent_id}");
191    pane_paths.iter().find_map(|(idx, path)| {
192        let base = path
193            .trim_end_matches('/')
194            .rsplit('/')
195            .next()
196            .unwrap_or(path);
197        (base == agent_id || base.ends_with(&suffix)).then_some(*idx)
198    })
199}
200
201/// Joins broker status rows with the tmux pane mapping and per-pane detected
202/// modes into inventory entries.
203///
204/// `modes` maps a pane index to its detected [`Mode`]; an agent whose pane is
205/// absent from the map (or unmatched) reports [`Mode::Unknown`].
206#[must_use]
207pub fn join_inventory<S: std::hash::BuildHasher>(
208    agents: Vec<StatusAgent>,
209    pane_paths: &[(usize, String)],
210    modes: &HashMap<usize, Mode, S>,
211) -> Vec<AgentEntry> {
212    agents
213        .into_iter()
214        .map(|a| {
215            let pane_index = match_pane(&a.agent_id, pane_paths);
216            let mode = pane_index
217                .and_then(|idx| modes.get(&idx).copied())
218                .unwrap_or(Mode::Unknown);
219            let cli = if a.cli.trim().is_empty() {
220                None
221            } else {
222                Some(a.cli)
223            };
224            AgentEntry {
225                branch_id: a.agent_id,
226                status: a.status,
227                last_seen_seconds: a.last_seen_seconds,
228                cli,
229                mode,
230                pane_index,
231            }
232        })
233        .collect()
234}
235
236/// Best-effort mode detection from an agent pane's title and recent capture.
237///
238/// Heuristic (design D1): an explicit accept-edits / bypass-permissions
239/// footer marks [`Mode::AcceptEdits`]; a visible interactive prompt marks
240/// [`Mode::Interactive`]; anything else is [`Mode::Unknown`]. The signal set
241/// is illustrative, not exhaustive — when in doubt the result is `Unknown`
242/// and consumers fall back to the safe delivery mode.
243#[must_use]
244pub fn detect_mode(pane_title: &str, capture: &str) -> Mode {
245    let hay = format!("{pane_title}\n{capture}").to_lowercase();
246    if hay.contains("accept edits")
247        || hay.contains("accept-edits")
248        || hay.contains("bypass permissions")
249    {
250        Mode::AcceptEdits
251    } else if hay.contains("? for shortcuts")
252        || hay.contains("do you want to proceed")
253        || hay.contains("do you want to allow")
254        || hay.contains("(y/n)")
255        || hay.contains("[y/n]")
256        || hay.contains("❯ 1. yes")
257    {
258        Mode::Interactive
259    } else {
260        Mode::Unknown
261    }
262}
263
264/// Rejection returned by [`validate_target`] for an unknown target.
265///
266/// This is the documented, stable error shape (design D6): every consumer —
267/// `/tell` today, the v1.0.0 MCP `publish_agent_feedback` later — surfaces the
268/// same `{ target, candidates }` rejection so unknown targets are refused
269/// consistently.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum ValidationError {
272    /// The named target is not in the inventory; `candidates` lists the
273    /// available agent identifiers.
274    UnknownTarget {
275        /// The rejected target identifier as typed by the user.
276        target: String,
277        /// The available agent identifiers (supervisor excluded), sorted.
278        candidates: Vec<String>,
279    },
280}
281
282impl fmt::Display for ValidationError {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::UnknownTarget { target, candidates } => {
286                write!(
287                    f,
288                    "unknown target `{target}`; available agents: {}",
289                    candidates.join(", ")
290                )
291            }
292        }
293    }
294}
295
296impl std::error::Error for ValidationError {}
297
298/// Validates a `/tell` target against the inventory.
299///
300/// Returns the matching [`AgentEntry`] (matching either slug or slash form),
301/// or a [`ValidationError::UnknownTarget`] carrying the candidate list so the
302/// caller can echo the available agents back to the user. This is the shared
303/// helper of design D6 — public so consumers outside the supervisor module
304/// can reuse the same validation semantics.
305///
306/// # Errors
307/// Returns [`ValidationError::UnknownTarget`] when no entry matches.
308pub fn validate_target<'a>(
309    inventory: &'a AgentInventory,
310    target_id: &str,
311) -> Result<&'a AgentEntry, ValidationError> {
312    inventory
313        .find(target_id)
314        .ok_or_else(|| ValidationError::UnknownTarget {
315            target: target_id.trim().to_string(),
316            candidates: inventory.candidate_ids(),
317        })
318}
319
320/// In-memory inventory cache with a freshness window (design D2).
321///
322/// Owned by the supervisor's sweep loop: the sweep stores a fresh snapshot at
323/// its cadence, and `/tell` / `/agents` reuse the cached snapshot while it is
324/// younger than `max_age`, rebuilding on demand only when stale. There is no
325/// on-disk cache — a supervisor restart produces a fresh inventory.
326#[derive(Debug)]
327pub struct InventoryCache {
328    snapshot: Option<AgentInventory>,
329    max_age: Duration,
330}
331
332impl InventoryCache {
333    /// Creates an empty cache with the given freshness window.
334    #[must_use]
335    pub fn new(max_age: Duration) -> Self {
336        Self {
337            snapshot: None,
338            max_age,
339        }
340    }
341
342    /// Creates an empty cache with a freshness window in seconds (the
343    /// `[supervisor.tell] inventory_max_age_seconds` config value).
344    #[must_use]
345    pub fn from_seconds(seconds: u64) -> Self {
346        Self::new(Duration::from_secs(seconds))
347    }
348
349    /// The configured freshness window.
350    #[must_use]
351    pub fn max_age(&self) -> Duration {
352        self.max_age
353    }
354
355    /// The cached snapshot, if any.
356    #[must_use]
357    pub fn snapshot(&self) -> Option<&AgentInventory> {
358        self.snapshot.as_ref()
359    }
360
361    /// Whether the cache holds a snapshot still within `max_age` as of `now`.
362    #[must_use]
363    pub fn is_fresh_at(&self, now: Instant) -> bool {
364        self.snapshot
365            .as_ref()
366            .is_some_and(|s| now.duration_since(s.refreshed_at) < self.max_age)
367    }
368
369    /// Stores a freshly-built snapshot (called by the sweep loop).
370    pub fn store(&mut self, snapshot: AgentInventory) {
371        self.snapshot = Some(snapshot);
372    }
373
374    /// Returns the cached snapshot when fresh as of `now`; otherwise calls
375    /// `refresh` to rebuild it, stores the result, and returns it.
376    ///
377    /// `refresh` is invoked at most once per stale lookup, so rapid
378    /// consecutive `/agents` within the freshness window trigger only one
379    /// rebuild (the broker is not re-polled while the snapshot is fresh).
380    ///
381    /// # Errors
382    /// Propagates any error from `refresh`; the previous snapshot (if any) is
383    /// left untouched on failure.
384    ///
385    /// # Panics
386    /// Does not panic in practice: when the cache is stale `refresh` populates
387    /// the snapshot before it is unwrapped, so the `Some` invariant holds.
388    pub fn get_or_refresh<F, E>(&mut self, now: Instant, refresh: F) -> Result<&AgentInventory, E>
389    where
390        F: FnOnce() -> Result<AgentInventory, E>,
391    {
392        if !self.is_fresh_at(now) {
393            let snapshot = refresh()?;
394            self.snapshot = Some(snapshot);
395        }
396        Ok(self
397            .snapshot
398            .as_ref()
399            .expect("snapshot present after refresh"))
400    }
401}
402
403/// Builds an inventory snapshot from the live broker and tmux session.
404///
405/// Polls broker `GET /status`, lists the session's panes with their
406/// `pane_current_path`, detects each pane's mode, and joins them into
407/// [`AgentEntry`] rows. The snapshot is stamped with the current [`Instant`]
408/// for cache freshness.
409///
410/// # Errors
411/// Returns [`PawError`] when the broker is unreachable, returns a non-200
412/// status, or emits unparseable `/status` JSON. tmux failures degrade
413/// gracefully: a pane listing that fails yields an empty mapping (all
414/// `pane_index` become `None`) rather than aborting the inventory.
415pub fn build_inventory(broker_url: &str, tmux_session: &str) -> Result<AgentInventory, PawError> {
416    let body = fetch_status_body(broker_url)?;
417    let agents = parse_status_agents(&body)?;
418    let pane_output = list_pane_paths(tmux_session).unwrap_or_default();
419    let pane_paths = parse_pane_paths(&pane_output);
420    let mut modes = HashMap::new();
421    for (idx, _) in &pane_paths {
422        modes.insert(*idx, detect_pane_mode(tmux_session, *idx));
423    }
424    let entries = join_inventory(agents, &pane_paths, &modes);
425    Ok(AgentInventory {
426        entries,
427        refreshed_at: Instant::now(),
428    })
429}
430
431/// Fetches and parses the broker `GET /status` agent list over HTTP.
432///
433/// Used by callers outside the dashboard process (e.g. the MCP server) that
434/// want a live per-agent snapshot. Returns an error when the broker is
435/// unreachable so the caller can degrade gracefully.
436pub fn fetch_status_agents_over_http(broker_url: &str) -> Result<Vec<StatusAgent>, PawError> {
437    parse_status_agents(&fetch_status_body(broker_url)?)
438}
439
440/// Fetches the raw `GET /status` JSON body over a minimal HTTP/1.1 request.
441fn fetch_status_body(broker_url: &str) -> Result<String, PawError> {
442    let addr = broker_url.strip_prefix("http://").unwrap_or(broker_url);
443    let socket_addr = if let Ok(a) = addr.parse() {
444        a
445    } else {
446        use std::net::ToSocketAddrs;
447        addr.to_socket_addrs()
448            .map_err(|e| PawError::SessionError(format!("invalid broker address {addr}: {e}")))?
449            .next()
450            .ok_or_else(|| {
451                PawError::SessionError(format!("broker address {addr} resolved to no addrs"))
452            })?
453    };
454
455    let mut stream = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(500))
456        .map_err(|e| PawError::SessionError(format!("failed to connect to broker: {e}")))?;
457    stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
458    stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
459
460    let request = format!("GET /status HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
461    stream
462        .write_all(request.as_bytes())
463        .map_err(|e| PawError::SessionError(format!("failed to write status request: {e}")))?;
464
465    let mut response = String::new();
466    let _ = stream.read_to_string(&mut response);
467
468    if !(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) {
469        return Err(PawError::SessionError(format!(
470            "broker /status returned non-200: {}",
471            response.lines().next().unwrap_or("<empty>")
472        )));
473    }
474
475    let body_start = response
476        .find("\r\n\r\n")
477        .map(|i| i + 4)
478        .ok_or_else(|| PawError::SessionError("malformed broker /status response".to_string()))?;
479    Ok(response[body_start..].to_string())
480}
481
482/// Runs `tmux list-panes` for the session, formatting each line as
483/// `<pane_index> <pane_current_path>`.
484fn list_pane_paths(session: &str) -> Result<String, PawError> {
485    let output = std::process::Command::new("tmux")
486        .args([
487            "list-panes",
488            "-t",
489            &format!("{session}:0"),
490            "-F",
491            "#{pane_index} #{pane_current_path}",
492        ])
493        .output()
494        .map_err(|e| PawError::SessionError(format!("tmux list-panes failed: {e}")))?;
495    if !output.status.success() {
496        return Err(PawError::SessionError(format!(
497            "tmux list-panes exited with {}",
498            output.status
499        )));
500    }
501    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
502}
503
504/// Captures a pane's title and recent content and detects its mode.
505fn detect_pane_mode(session: &str, pane_index: usize) -> Mode {
506    let title = std::process::Command::new("tmux")
507        .args([
508            "display-message",
509            "-t",
510            &format!("{session}:0.{pane_index}"),
511            "-p",
512            "#{pane_title}",
513        ])
514        .output()
515        .ok()
516        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
517        .unwrap_or_default();
518    let capture =
519        crate::supervisor::permission_prompt::capture_pane(session, pane_index).unwrap_or_default();
520    detect_mode(&title, &capture)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use std::cell::Cell;
527
528    const STATUS_JSON: &str = r#"{
529        "git_paw": true,
530        "version": "0.6.0",
531        "uptime_seconds": 42,
532        "agents": [
533            {"agent_id": "feat-auth", "cli": "claude", "status": "working", "last_seen_seconds": 3, "summary": ""},
534            {"agent_id": "feat-api", "cli": "", "status": "blocked", "last_seen_seconds": 90, "summary": ""},
535            {"agent_id": "supervisor", "cli": "claude", "status": "working", "last_seen_seconds": 1, "summary": ""}
536        ]
537    }"#;
538
539    fn fixture_inventory() -> AgentInventory {
540        let agents = parse_status_agents(STATUS_JSON).unwrap();
541        // Non-sequential pane mapping: feat-api on a lower index than feat-auth.
542        let panes = parse_pane_paths(
543            "0 /home/user/myproj\n1 /home/user/myproj-feat-api\n2 /home/user/myproj-feat-auth\n",
544        );
545        let mut modes = HashMap::new();
546        modes.insert(2usize, Mode::AcceptEdits);
547        let entries = join_inventory(agents, &panes, &modes);
548        AgentInventory {
549            entries,
550            refreshed_at: Instant::now(),
551        }
552    }
553
554    #[test]
555    fn parse_status_agents_reads_all_rows() {
556        let agents = parse_status_agents(STATUS_JSON).unwrap();
557        assert_eq!(agents.len(), 3);
558        assert_eq!(agents[0].agent_id, "feat-auth");
559        assert_eq!(agents[0].cli, "claude");
560        assert_eq!(agents[1].last_seen_seconds, 90);
561    }
562
563    #[test]
564    fn parse_pane_paths_handles_spaces_and_skips_garbage() {
565        let panes =
566            parse_pane_paths("0 /home/user/my proj\n1 /home/user/wt-feat-x\nnot-a-pane line\n");
567        assert_eq!(panes.len(), 2);
568        assert_eq!(panes[0], (0, "/home/user/my proj".to_string()));
569        assert_eq!(panes[1], (1, "/home/user/wt-feat-x".to_string()));
570    }
571
572    #[test]
573    fn pane_index_is_path_resolved_not_ordered() {
574        let inv = fixture_inventory();
575        let api = inv.find("feat-api").unwrap();
576        let auth = inv.find("feat-auth").unwrap();
577        // Resolution is by worktree path, NOT alphabetical / registration order:
578        // feat-api landed on pane 1, feat-auth on pane 2.
579        assert_eq!(api.pane_index, Some(1));
580        assert_eq!(auth.pane_index, Some(2));
581    }
582
583    #[test]
584    fn match_pane_does_not_partial_match_prefix() {
585        let panes = parse_pane_paths("1 /home/user/proj-feat-api\n");
586        // `feat-a` must NOT match `…-feat-api`.
587        assert_eq!(match_pane("feat-a", &panes), None);
588        assert_eq!(match_pane("feat-api", &panes), Some(1));
589    }
590
591    #[test]
592    fn supervisor_resolves_to_pane_zero() {
593        let inv = fixture_inventory();
594        let sup = inv.find("supervisor").unwrap();
595        assert_eq!(sup.pane_index, Some(0));
596    }
597
598    #[test]
599    fn empty_cli_maps_to_none() {
600        let inv = fixture_inventory();
601        assert_eq!(
602            inv.find("feat-auth").unwrap().cli.as_deref(),
603            Some("claude")
604        );
605        assert_eq!(inv.find("feat-api").unwrap().cli, None);
606    }
607
608    #[test]
609    fn agent_removed_mid_grid_drops_pane_index() {
610        // feat-api's pane was removed (middle-grid remove); its broker row
611        // lingers until the next sweep but no pane matches → pane_index None.
612        let agents = parse_status_agents(STATUS_JSON).unwrap();
613        let panes = parse_pane_paths("0 /home/user/myproj\n2 /home/user/myproj-feat-auth\n");
614        let entries = join_inventory(agents, &panes, &HashMap::new());
615        let inv = AgentInventory {
616            entries,
617            refreshed_at: Instant::now(),
618        };
619        assert_eq!(inv.find("feat-api").unwrap().pane_index, None);
620        assert_eq!(inv.find("feat-auth").unwrap().pane_index, Some(2));
621    }
622
623    #[test]
624    fn detect_mode_accept_edits() {
625        assert_eq!(
626            detect_mode("", "⏵⏵ accept edits on (shift+tab to cycle)"),
627            Mode::AcceptEdits
628        );
629        assert_eq!(
630            detect_mode("claude — bypass permissions", ""),
631            Mode::AcceptEdits
632        );
633    }
634
635    #[test]
636    fn detect_mode_interactive_prompt() {
637        assert_eq!(
638            detect_mode("", "Do you want to proceed?\n❯ 1. Yes"),
639            Mode::Interactive
640        );
641    }
642
643    #[test]
644    fn detect_mode_unknown_when_no_signal() {
645        assert_eq!(
646            detect_mode("", "Boondoggling… (esc to interrupt)"),
647            Mode::Unknown
648        );
649    }
650
651    #[test]
652    fn unknown_mode_signals_join_to_unknown() {
653        let inv = fixture_inventory();
654        // feat-api's pane had no detected mode in the modes map → Unknown.
655        assert_eq!(inv.find("feat-api").unwrap().mode, Mode::Unknown);
656        assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::AcceptEdits);
657    }
658
659    #[test]
660    fn validate_target_accepts_slug_and_slash_form() {
661        let inv = fixture_inventory();
662        assert!(validate_target(&inv, "feat-auth").is_ok());
663        // slash form resolves to the same slug entry
664        assert_eq!(
665            validate_target(&inv, "feat/auth").unwrap().branch_id,
666            "feat-auth"
667        );
668    }
669
670    #[test]
671    fn validate_target_unknown_returns_candidate_list() {
672        let inv = fixture_inventory();
673        let err = validate_target(&inv, "feat/ghost").unwrap_err();
674        match err {
675            ValidationError::UnknownTarget { target, candidates } => {
676                assert_eq!(target, "feat/ghost");
677                // supervisor excluded; sorted.
678                assert_eq!(
679                    candidates,
680                    vec!["feat-api".to_string(), "feat-auth".to_string()]
681                );
682            }
683        }
684    }
685
686    #[test]
687    fn validation_error_display_lists_candidates() {
688        let err = ValidationError::UnknownTarget {
689            target: "feat/ghost".to_string(),
690            candidates: vec!["feat/a".to_string(), "feat/b".to_string()],
691        };
692        let msg = err.to_string();
693        assert!(msg.contains("feat/ghost"));
694        assert!(msg.contains("feat/a, feat/b"), "got: {msg}");
695    }
696
697    // --- InventoryCache (design D2) ---
698
699    fn snapshot_now() -> AgentInventory {
700        AgentInventory {
701            entries: Vec::new(),
702            refreshed_at: Instant::now(),
703        }
704    }
705
706    #[test]
707    fn cache_starts_empty_and_not_fresh() {
708        let cache = InventoryCache::from_seconds(60);
709        assert!(cache.snapshot().is_none());
710        assert!(!cache.is_fresh_at(Instant::now()));
711    }
712
713    #[test]
714    fn rapid_lookups_within_window_refresh_once() {
715        let calls = Cell::new(0u32);
716        let mut cache = InventoryCache::from_seconds(60);
717        let refresh = || {
718            calls.set(calls.get() + 1);
719            Ok::<_, ()>(snapshot_now())
720        };
721        // Two consecutive lookups within the freshness window.
722        cache.get_or_refresh(Instant::now(), refresh).unwrap();
723        let refresh2 = || {
724            calls.set(calls.get() + 1);
725            Ok::<_, ()>(snapshot_now())
726        };
727        cache.get_or_refresh(Instant::now(), refresh2).unwrap();
728        assert_eq!(calls.get(), 1, "fresh cache must not re-poll the broker");
729    }
730
731    #[test]
732    fn stale_snapshot_triggers_refresh() {
733        let mut cache = InventoryCache::from_seconds(60);
734        // Seed a snapshot timestamped 2min in the past → stale at 60s max-age.
735        let stale = AgentInventory {
736            entries: Vec::new(),
737            refreshed_at: Instant::now()
738                .checked_sub(Duration::from_mins(2))
739                .expect("instant in range"),
740        };
741        cache.store(stale);
742        assert!(!cache.is_fresh_at(Instant::now()));
743
744        let calls = Cell::new(0u32);
745        cache
746            .get_or_refresh(Instant::now(), || {
747                calls.set(calls.get() + 1);
748                Ok::<_, ()>(snapshot_now())
749            })
750            .unwrap();
751        assert_eq!(calls.get(), 1, "stale cache must rebuild");
752        assert!(cache.is_fresh_at(Instant::now()));
753    }
754
755    // --- build_inventory end-to-end against a fake broker (IO orchestration) ---
756
757    /// Spawns a one-shot HTTP server that answers a single `GET /status` with
758    /// `body`, and returns its `http://addr` URL.
759    fn spawn_status_server(body: &'static str) -> String {
760        use std::net::TcpListener;
761        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
762        let addr = listener.local_addr().expect("local addr");
763        std::thread::spawn(move || {
764            if let Ok((mut stream, _)) = listener.accept() {
765                let mut buf = [0u8; 1024];
766                let _ = stream.read(&mut buf);
767                let resp = format!(
768                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
769                    body.len()
770                );
771                let _ = stream.write_all(resp.as_bytes());
772            }
773        });
774        format!("http://{addr}")
775    }
776
777    #[test]
778    fn build_inventory_against_fake_broker_no_tmux() {
779        let url = spawn_status_server(STATUS_JSON);
780        // The tmux session does not exist, so list-panes fails and every
781        // pane_index degrades to None (except the supervisor's pane-0 rule).
782        let inv = build_inventory(&url, "paw-nonexistent-xyz-123").expect("inventory builds");
783        assert_eq!(inv.entries.len(), 3);
784        assert_eq!(inv.find("feat-auth").unwrap().pane_index, None);
785        assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::Unknown);
786        assert_eq!(inv.find("supervisor").unwrap().pane_index, Some(0));
787    }
788
789    #[test]
790    fn build_inventory_unreachable_broker_errors() {
791        // Port 1 on loopback refuses immediately → connect error propagates.
792        assert!(build_inventory("http://127.0.0.1:1", "x").is_err());
793    }
794
795    #[test]
796    fn parse_status_agents_rejects_garbage() {
797        assert!(parse_status_agents("not json at all").is_err());
798    }
799
800    #[test]
801    fn detect_pane_mode_helper_on_dead_session_is_unknown() {
802        // capture + title both fail for a nonexistent session/pane → Unknown.
803        assert_eq!(
804            detect_pane_mode("paw-nonexistent-xyz-123", 9),
805            Mode::Unknown
806        );
807    }
808}