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-protocol/src/lib.rs
T

497 lines
18 KiB
Rust

//! Wire types shared between the Maestro daemon and its clients (CLI, TUI, web).
//!
//! Maestro is single-host: one daemon owns every session on the machine, and
//! clients talk to it directly over `/api/*`. There is no registry, no peer
//! federation, and no authentication layer — reaching the daemon's bind address
//! *is* the authorization, same trust model as tmux itself.
#![deny(missing_debug_implementations)]
#[cfg(feature = "client")]
pub mod client;
pub mod routes;
#[cfg(feature = "client")]
pub use client::{ClientError, DaemonClient};
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
/// The protocol version this crate implements. Advertised in `GET /api/info`.
pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION");
/// True when a daemon advertising `daemon_version` speaks the same protocol as
/// this build.
///
/// Compared on the major component only, per semver: within a major, added
/// fields are `#[serde(default)]` and so are forward-compatible. An empty or
/// unparseable version is treated as compatible — daemons that predate the
/// field should not trip a warning.
pub fn is_compatible(daemon_version: &str) -> bool {
fn major(v: &str) -> Option<&str> {
let m = v.split('.').next()?;
(!m.is_empty() && m.bytes().all(|b| b.is_ascii_digit())).then_some(m)
}
match (major(daemon_version), major(PROTOCOL_VERSION)) {
(Some(theirs), Some(ours)) => theirs == ours,
_ => true,
}
}
/// A live or recently-dead tmux session managed by the daemon.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub id: Uuid,
pub cwd: PathBuf,
pub command: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub metadata: HashMap<String, Value>,
pub status: SessionStatus,
pub created_at: DateTime<Utc>,
/// Current foreground command in the session's pane (e.g. `claude`,
/// `bash`), as tmux reports it — distinct from the `command` it was spawned
/// with. Daemon-derived (NAP-340); `None` until the daemon's activity
/// watcher has observed it. All three activity fields default on the wire so
/// older daemons/payloads stay forward-compatible.
#[serde(default)]
pub foreground: Option<String>,
/// Inferred activity dimension, orthogonal to [`SessionStatus`]
/// (NAP-340). Derived from structured tmux/`/proc` state, never from the
/// byte stream.
#[serde(default)]
pub activity: ActivityState,
/// Wall-clock time of the session's most recent observed PTY output.
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
/// from daemons that predate the field.
#[serde(default = "epoch")]
pub last_activity_at: DateTime<Utc>,
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
/// from [`Session::cwd`], the directory it was spawned in. The activity
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
/// directory-based session tree. `None` until first observed, and on payloads
/// from daemons that predate the field.
#[serde(default)]
pub current_cwd: Option<PathBuf>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionStatus {
Running,
Dead,
}
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
/// inspection — never by parsing the terminal byte stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ActivityState {
/// Producing output, or the foreground process is busy.
Active,
/// Foreground process alive but quiescent. The forward-compatible default.
#[default]
Idle,
/// Blocked on a `read()` from the pty — waiting on the user.
AwaitingInput,
}
/// Optional worktree request attached to a `CreateSessionRequest`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
#[serde(default = "default_true")]
pub create_branch: bool,
}
fn default_true() -> bool {
true
}
/// HTTP `POST /api/sessions` body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateSessionRequest {
pub cwd: PathBuf,
pub command: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub metadata: HashMap<String, Value>,
#[serde(default)]
pub worktree: Option<WorktreeSpec>,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
/// Drives the spawn form's working-directory autocomplete (NAP-329).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirListing {
/// The resolved absolute directory that was listed, after the daemon
/// expanded `~`/relative/empty inputs against its own home dir. The client
/// uses it to know what an abbreviated query resolved to.
pub path: PathBuf,
/// Immediate subdirectory names (not full paths), sorted case-insensitively.
/// Directories only — the spawn form's targets are always dirs. Hidden
/// directories (dotfiles) are excluded.
pub entries: Vec<String>,
/// True when `entries` was truncated at the daemon's cap; the client can
/// surface "narrow your search" rather than implying the dir is small.
#[serde(default)]
pub truncated: bool,
}
/// One deduplicated recent working directory for the spawn form's "recent
/// dirs" picker (#24/#26). Newest use of a `cwd`, with the command last run
/// there. Mirrors the daemon-local `spawn_history::RecentDir`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentDir {
pub cwd: PathBuf,
pub last_command: String,
pub last_used: DateTime<Utc>,
}
/// Result of `GET /api/recent-dirs`: recently-used working dirs, newest-first
/// and deduped by `cwd` (#26). `truncated` mirrors [`DirListing`]: true when
/// the daemon capped the list.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecentDirs {
pub dirs: Vec<RecentDir>,
#[serde(default)]
pub truncated: bool,
}
/// Result of a `GET /api/gitroot` probe: the enclosing git repository toplevel
/// of a path, or `None` when the path isn't inside a repo (#26). Drives the
/// spawn form's worktree section — a present `root` is the inferred
/// `base_repo`; `None` disables the worktree toggle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRoot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<PathBuf>,
}
/// Structured failure used everywhere we don't want to leak a string-only error.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorInfo {
pub code: String,
pub message: String,
}
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
///
/// The daemon builds it, the CLI and TUI decode it to surface the daemon's own
/// message, and `maestro-web` uses it both to report proxy failures and to
/// recover the rejection reason from an upstream WebSocket handshake. Before it
/// lived here that shape was hand-rolled in four places, one of them by
/// escaping quotes into a format string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
pub error: ErrorInfo,
}
impl ApiError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: ErrorInfo {
code: code.into(),
message: message.into(),
},
}
}
/// The daemon's human-readable reason, for clients that only render text.
pub fn message(&self) -> &str {
&self.error.message
}
}
/// One command tile offered by the spawn form.
///
/// Lives here rather than in `maestro-config` because it is on the wire inside
/// [`DaemonInfo`]; `maestro-config` re-exports it so the on-disk `[spawn]`
/// schema and the payload stay one type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
}
/// Payload of `GET /api/info` — daemon identity, protocol version, and the
/// spawn presets clients render as one-click tiles.
///
/// Previously declared three times (daemon, CLI, TypeScript), and the CLI's
/// copy silently dropped `version` and `presets`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonInfo {
#[serde(default)]
pub hostname: String,
/// The `PROTOCOL_VERSION` the daemon implements.
#[serde(default)]
pub version: String,
/// Home directory of the OS user the daemon runs as, so clients can prefill
/// a spawn form's working directory. Empty if unresolved.
#[serde(default)]
pub home_dir: String,
#[serde(default)]
pub presets: Vec<SpawnPreset>,
}
// ---------------------------------------------------------------------------
// Catalog events (`WS /api/events`)
// ---------------------------------------------------------------------------
/// Messages the daemon broadcasts to clients over `WS /api/events`.
///
/// A subscriber receives a single `Snapshot` immediately on connect, then a
/// stream of deltas as sessions are created, updated, and killed. Skipped
/// events (broadcast channel lag) surface as `Lagged`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CatalogEvent {
Snapshot {
sessions: Vec<Session>,
},
SessionAdded {
session: Session,
},
SessionRemoved {
id: Uuid,
},
SessionUpdated {
session: Session,
},
/// Server-issued notification that the broadcast queue dropped frames.
/// Clients should re-fetch a full snapshot.
Lagged {
skipped: u64,
},
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
/// Text-frame control messages on a `WS /api/attach/:id` connection.
/// PTY bytes travel in binary frames and are not modeled here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClientControl {
Resize { cols: u16, rows: u16 },
Ping,
Pong,
Error(ErrorInfo),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_compatibility_is_major_only() {
assert!(is_compatible(PROTOCOL_VERSION));
let ours_major = PROTOCOL_VERSION.split('.').next().unwrap();
assert!(is_compatible(&format!("{ours_major}.99.99")));
assert!(!is_compatible("99.0.0"));
}
/// A daemon predating the field sends nothing; that must not read as a
/// mismatch.
#[test]
fn version_compatibility_tolerates_unknown_versions() {
assert!(is_compatible(""));
assert!(is_compatible("unknown"));
}
fn round_trip<T>(value: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
{
let json = serde_json::to_string(value).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
fn sample_session() -> Session {
Session {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
cwd: PathBuf::from("/home/naps62/projects/maestro"),
command: "claude".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
foreground: Some("claude".into()),
activity: ActivityState::Active,
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
.unwrap()
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
}
}
#[test]
fn session_activity_fields_default_on_legacy_payload() {
// A pre-NAP-340 daemon omits the activity fields; they must deserialize
// to the forward-compat defaults rather than failing.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert!(s.foreground.is_none());
assert_eq!(s.activity, ActivityState::Idle);
assert_eq!(s.last_activity_at, epoch());
assert!(s.current_cwd.is_none());
}
#[test]
fn activity_state_serializes_pascal_case_and_defaults_idle() {
assert_eq!(
serde_json::to_value(ActivityState::AwaitingInput).unwrap(),
serde_json::json!("AwaitingInput")
);
assert_eq!(ActivityState::default(), ActivityState::Idle);
}
#[test]
fn session_round_trips() {
let s = sample_session();
assert_eq!(s, round_trip(&s));
}
#[test]
fn session_status_serializes_pascal_case() {
assert_eq!(
serde_json::to_value(SessionStatus::Running).unwrap(),
serde_json::json!("Running")
);
assert_eq!(
serde_json::to_value(SessionStatus::Dead).unwrap(),
serde_json::json!("Dead")
);
}
#[test]
fn create_session_request_with_worktree_round_trips() {
let req = CreateSessionRequest {
cwd: PathBuf::from("/work"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
assert_eq!(req, round_trip(&req));
}
#[test]
fn create_session_request_omits_optional_worktree() {
let json = serde_json::json!({
"cwd": "/work",
"command": "bash"
});
let req: CreateSessionRequest = serde_json::from_value(json).unwrap();
assert!(req.worktree.is_none());
assert!(req.env.is_empty());
assert!(req.metadata.is_empty());
}
/// A client that predates the new/existing discriminator sends no
/// `create_branch`; the daemon must read that as "create a new branch" —
/// exactly what every payload meant before the field existed (#56).
#[test]
fn worktree_spec_defaults_on_legacy_payload() {
let json = serde_json::json!({
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
fn worktree_spec_round_trips_existing_branch_mode() {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
let variants = vec![
CatalogEvent::Snapshot {
sessions: vec![session.clone()],
},
CatalogEvent::SessionAdded {
session: session.clone(),
},
CatalogEvent::SessionRemoved { id: session.id },
CatalogEvent::SessionUpdated { session },
CatalogEvent::Lagged { skipped: 3 },
];
for v in variants {
assert_eq!(v, round_trip(&v), "variant did not round-trip");
}
let v = serde_json::to_value(CatalogEvent::Lagged { skipped: 3 }).unwrap();
assert_eq!(v["type"], "Lagged");
assert_eq!(v["skipped"], 3);
}
#[test]
fn client_control_round_trips_and_tags() {
let resize = ClientControl::Resize {
cols: 120,
rows: 40,
};
let v = serde_json::to_value(&resize).unwrap();
assert_eq!(v["type"], "Resize");
assert_eq!(v["cols"], 120);
assert_eq!(v["rows"], 40);
for c in [
ClientControl::Ping,
ClientControl::Pong,
ClientControl::Error(ErrorInfo {
code: "bad_frame".into(),
message: "decode failed".into(),
}),
resize,
] {
assert_eq!(c, round_trip(&c));
}
}
}