497 lines
18 KiB
Rust
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));
|
|
}
|
|
}
|
|
}
|