This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/panes.rs
T
Miguel PalhasandClaude Opus 5 b20651c33e
ci / web (pull_request) Successful in 27s
ci / rust (pull_request) Successful in 1m40s
ci / web (push) Successful in 35s
ci / rust (push) Successful in 2m23s
fix(maestro): close three holes in the wire contract
Each was a typed field that type-checked on both sides and carried
nothing across.

SessionStatus::Dead was never constructed by the daemon, yet the TUI, CLI
and SPA all render it and DESIGN.md specifies the exited badge. Sessions
were removed on death, never marked. They are now marked Dead and kept
until the operator dismisses them, with the PtyBridge sealed rather than
dropped: attached clients still get a Close frame (NAP-226), and the
replay buffer survives so a later attach can read what a crashed agent
printed. Previously that output was destroyed. Dead sessions are
read-only and do not survive a daemon restart.

CreateSessionRequest.env was accepted, stored, echoed back and dropped —
new_session_piped had no env parameter. It now reaches tmux as sorted
`-e KEY=VAL` flags. A key containing `=` or NUL is rejected, since tmux
would parse it as a different assignment than the caller wrote.

PROTOCOL_VERSION was written by the daemon and read by nobody. Adds
`maestro_protocol::is_compatible`, compared on the major component only;
`maestro list` shows the daemon version and the TUI warns on mismatch.

Also stops mapping every non-404 session error to 502: an invalid env key
is a client mistake and now answers 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:27:51 +01:00

291 lines
8.1 KiB
Rust

//! The tmux operations [`crate::session::SessionManager`] depends on.
//!
//! Behind a trait so the manager's *decisions* — spawn rollback ordering,
//! liveness diffing, activity change-detection — can be exercised without
//! forking a tmux server. [`Tmux`] is the production adapter; [`FakePanes`] is
//! the one tests drive.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::sync::Mutex;
use uuid::Uuid;
use crate::tmux::{PaneInfo, Tmux, TmuxError};
/// Every tmux call the session manager makes.
///
/// Object-safe on purpose: `Inner` holds an `Arc<dyn Panes>` rather than a type
/// parameter, which would otherwise spread through `LocalApiState`, `attach`
/// and `cli`. One vtable hop is free next to a `fork`+`exec`.
pub trait Panes: std::fmt::Debug + Send + Sync + 'static {
fn new_session_piped(
&self,
id: Uuid,
cwd: &Path,
command: &str,
session_env: &BTreeMap<String, String>,
sink_command: &str,
) -> Result<(), TmuxError>;
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError>;
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError>;
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError>;
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError>;
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
}
impl Panes for Tmux {
fn new_session_piped(
&self,
id: Uuid,
cwd: &Path,
command: &str,
session_env: &BTreeMap<String, String>,
sink_command: &str,
) -> Result<(), TmuxError> {
Tmux::new_session_piped(self, id, cwd, command, session_env, sink_command)
}
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> {
Tmux::kill_session(self, id)
}
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError> {
Tmux::list_sessions(self)
}
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
Tmux::pane_info(self, id)
}
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> {
Tmux::send_keys(self, id, bytes)
}
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError> {
Tmux::pipe_pane(self, id, sink_command)
}
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
Tmux::resize_window(self, id, cols, rows)
}
}
// ---------------------------------------------------------------------------
// Test adapter
// ---------------------------------------------------------------------------
/// Which [`Panes`] call a [`FakePanes`] expectation targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Op {
NewSessionPiped,
KillSession,
ListSessions,
PaneInfo,
SendKeys,
PipePane,
ResizeWindow,
}
/// One recorded call, in the order the manager made it. Rollback ordering is a
/// sequence property, so the tests that matter assert on this.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Call {
NewSessionPiped {
id: Uuid,
command: String,
env: BTreeMap<String, String>,
},
KillSession(Uuid),
ListSessions,
PaneInfo(Uuid),
SendKeys {
id: Uuid,
bytes: Vec<u8>,
},
PipePane(Uuid),
ResizeWindow {
id: Uuid,
cols: u16,
rows: u16,
},
}
/// In-memory [`Panes`] for tests.
///
/// Lives in `src/` rather than a test module because the daemon's integration
/// tests are separate crates, which `#[cfg(test)]` cannot reach and which
/// cannot enable a feature on their own crate.
#[derive(Debug, Default)]
pub struct FakePanes {
state: Mutex<FakeState>,
}
#[derive(Debug, Default)]
struct FakeState {
live: HashSet<Uuid>,
panes: HashMap<Uuid, PaneInfo>,
calls: Vec<Call>,
fail: HashMap<Op, String>,
}
impl FakePanes {
pub fn new() -> Self {
Self::default()
}
/// Make every call to `op` fail with `stderr`.
pub fn fail(&self, op: Op, stderr: impl Into<String>) {
self.lock().fail.insert(op, stderr.into());
}
/// Pretend a session exists without the manager having created it — the
/// recovery path's starting condition.
pub fn add_live(&self, id: Uuid) {
self.lock().live.insert(id);
}
/// Drop a session behind the manager's back, the way a command exiting or
/// an external `tmux kill-session` would.
pub fn remove_live(&self, id: Uuid) {
self.lock().live.remove(&id);
}
pub fn is_live(&self, id: Uuid) -> bool {
self.lock().live.contains(&id)
}
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
self.lock().panes.insert(id, info);
}
/// Every call so far, in order.
pub fn calls(&self) -> Vec<Call> {
self.lock().calls.clone()
}
fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> {
self.state.lock().expect("FakePanes mutex poisoned")
}
fn record(&self, call: Call, op: Op) -> Result<(), TmuxError> {
let mut st = self.lock();
st.calls.push(call);
match st.fail.get(&op) {
Some(stderr) => Err(TmuxError::NonZero {
code: 1,
stderr: stderr.clone(),
}),
None => Ok(()),
}
}
}
impl Panes for FakePanes {
fn new_session_piped(
&self,
id: Uuid,
_cwd: &Path,
command: &str,
session_env: &BTreeMap<String, String>,
_sink_command: &str,
) -> Result<(), TmuxError> {
self.record(
Call::NewSessionPiped {
id,
command: command.to_owned(),
env: session_env.clone(),
},
Op::NewSessionPiped,
)?;
self.lock().live.insert(id);
Ok(())
}
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> {
self.record(Call::KillSession(id), Op::KillSession)?;
self.lock().live.remove(&id);
Ok(())
}
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError> {
self.record(Call::ListSessions, Op::ListSessions)?;
Ok(self.lock().live.iter().copied().collect())
}
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
self.record(Call::PaneInfo(id), Op::PaneInfo)?;
Ok(self.lock().panes.get(&id).cloned())
}
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> {
self.record(
Call::SendKeys {
id,
bytes: bytes.to_vec(),
},
Op::SendKeys,
)
}
fn pipe_pane(&self, id: Uuid, _sink_command: &str) -> Result<(), TmuxError> {
self.record(Call::PipePane(id), Op::PipePane)
}
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_calls_in_order_and_tracks_liveness() {
let fake = FakePanes::new();
let id = Uuid::new_v4();
fake.new_session_piped(id, Path::new("/tmp"), "bash", &BTreeMap::new(), "cat >> /x")
.unwrap();
assert!(fake.is_live(id));
fake.kill_session(id).unwrap();
assert!(!fake.is_live(id));
assert_eq!(
fake.calls(),
vec![
Call::NewSessionPiped {
id,
command: "bash".into(),
env: BTreeMap::new(),
},
Call::KillSession(id),
]
);
}
#[test]
fn injected_failure_still_records_the_attempt() {
let fake = FakePanes::new();
let id = Uuid::new_v4();
fake.fail(Op::NewSessionPiped, "boom");
assert!(fake
.new_session_piped(id, Path::new("/tmp"), "bash", &BTreeMap::new(), "cat")
.is_err());
assert!(
!fake.is_live(id),
"a failed spawn must not register a session"
);
assert_eq!(fake.calls().len(), 1);
}
}