From 777d246aab8c80697a937ce2b19ce8dbf7b14b5d Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Sun, 24 May 2026 12:35:16 +0000 Subject: [PATCH] feat(maestro): add maestro-protocol crate with wire types Introduces the maestro-protocol crate (NAP-211): the shared serde types that the daemon, registry, and clients all serialize across WebSocket boundaries. Covers: - Session, SessionStatus - CreateSessionRequest with optional WorktreeSpec - RpcResult reply envelope - DaemonOutbound (Hello, snapshot, add/remove/update, Heartbeat, request results) - DaemonInbound (Ack, Error, CreateSession, KillSession) - ClientControl text frames (Resize, Ping, Pong, Error) Schema mirrors the wire-protocol tables in the design spec. Unit tests round-trip every variant and assert tag/discriminator stability so future renames break loudly. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/maestro-protocol/Cargo.toml | 17 ++ crates/maestro-protocol/src/lib.rs | 356 +++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 crates/maestro-protocol/Cargo.toml create mode 100644 crates/maestro-protocol/src/lib.rs diff --git a/crates/maestro-protocol/Cargo.toml b/crates/maestro-protocol/Cargo.toml new file mode 100644 index 0000000..60e482d --- /dev/null +++ b/crates/maestro-protocol/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "maestro-protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "Shared wire types for Maestro daemon/registry/client communication" + +[dependencies] +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs new file mode 100644 index 0000000..bc3bdf5 --- /dev/null +++ b/crates/maestro-protocol/src/lib.rs @@ -0,0 +1,356 @@ +//! Wire types shared between the Maestro daemon, registry, and clients. +//! +//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` § "Wire protocol" +//! and § "Session model" for the source-of-truth schema. + +#![deny(missing_debug_implementations)] + +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +/// A correlation token used to pair request/response messages over a single WS. +pub type ReplyTo = Uuid; + +/// The protocol version this crate implements. Daemons advertise it in `Hello`. +pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// A live or recently-dead tmux session managed by a daemon. +/// +/// The daemon is the source of truth; the registry caches snapshots. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Session { + pub id: Uuid, + pub daemon_id: String, + pub cwd: PathBuf, + pub command: String, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub metadata: HashMap, + pub status: SessionStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SessionStatus { + Running, + Dead, +} + +/// Optional worktree request attached to a `CreateSessionRequest`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeSpec { + pub base_repo: PathBuf, + pub branch: String, + #[serde(default)] + pub cleanup_on_kill: bool, +} + +/// HTTP `POST /api/sessions` body, also forwarded daemon-ward inside +/// `DaemonInbound::CreateSession`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateSessionRequest { + pub daemon_id: String, + pub cwd: PathBuf, + pub command: String, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub metadata: HashMap, + #[serde(default)] + pub worktree: Option, +} + +/// 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, +} + +/// Generic request-reply result body. Used by `CreateSessionResult`, +/// `KillSessionResult`, and any future correlated reply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum RpcResult { + Ok { value: T }, + Err { error: ErrorInfo }, +} + +// --------------------------------------------------------------------------- +// Daemon ↔ Registry messages +// --------------------------------------------------------------------------- + +/// Messages a daemon sends to the registry over its outbound WS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DaemonOutbound { + Hello { + daemon_id: String, + hostname: String, + version: String, + token: String, + }, + SessionsSnapshot { + sessions: Vec, + }, + SessionAdded { + session: Session, + }, + SessionRemoved { + id: Uuid, + }, + SessionUpdated { + session: Session, + }, + Heartbeat, + CreateSessionResult { + reply_to: ReplyTo, + result: RpcResult, + }, + KillSessionResult { + reply_to: ReplyTo, + result: RpcResult<()>, + }, +} + +/// Messages the registry sends back to a daemon over the same WS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DaemonInbound { + Ack, + Error(ErrorInfo), + CreateSession { + reply_to: ReplyTo, + request: CreateSessionRequest, + }, + KillSession { + reply_to: ReplyTo, + id: Uuid, + }, +} + +// --------------------------------------------------------------------------- +// Client ↔ Daemon control messages (text frames on the attach WS) +// --------------------------------------------------------------------------- + +/// Text-frame control messages on a `WS /sessions/:id/stream` 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::*; + + fn round_trip(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), + daemon_id: "laptop".into(), + 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())), + ("label".into(), Value::String("personal/maestro".into())), + ]), + status: SessionStatus::Running, + created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + } + } + + #[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 { + daemon_id: "dev-vm".into(), + 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, + }), + }; + assert_eq!(req, round_trip(&req)); + } + + #[test] + fn create_session_request_omits_optional_worktree() { + let json = serde_json::json!({ + "daemon_id": "dev-vm", + "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()); + } + + #[test] + fn rpc_result_ok_and_err_round_trip() { + let ok: RpcResult = RpcResult::Ok { + value: sample_session(), + }; + let err: RpcResult = RpcResult::Err { + error: ErrorInfo { + code: "tmux_failed".into(), + message: "session name in use".into(), + }, + }; + assert_eq!(ok, round_trip(&ok)); + assert_eq!(err, round_trip(&err)); + } + + #[test] + fn daemon_outbound_hello_round_trip_and_tag() { + let hello = DaemonOutbound::Hello { + daemon_id: "laptop".into(), + hostname: "naps-laptop".into(), + version: PROTOCOL_VERSION.into(), + token: "shhh".into(), + }; + let v = serde_json::to_value(&hello).unwrap(); + assert_eq!(v["type"], "Hello"); + assert_eq!(v["daemon_id"], "laptop"); + assert_eq!(hello, round_trip(&hello)); + } + + #[test] + fn daemon_outbound_all_variants_round_trip() { + let session = sample_session(); + let reply_to = Uuid::from_u128(1); + let variants = vec![ + DaemonOutbound::SessionsSnapshot { + sessions: vec![session.clone()], + }, + DaemonOutbound::SessionAdded { + session: session.clone(), + }, + DaemonOutbound::SessionRemoved { id: session.id }, + DaemonOutbound::SessionUpdated { + session: session.clone(), + }, + DaemonOutbound::Heartbeat, + DaemonOutbound::CreateSessionResult { + reply_to, + result: RpcResult::Ok { + value: session.clone(), + }, + }, + DaemonOutbound::KillSessionResult { + reply_to, + result: RpcResult::Ok { value: () }, + }, + ]; + for v in variants { + assert_eq!(v, round_trip(&v), "variant did not round-trip"); + } + } + + #[test] + fn daemon_inbound_all_variants_round_trip() { + let reply_to = Uuid::from_u128(7); + let create = DaemonInbound::CreateSession { + reply_to, + request: CreateSessionRequest { + daemon_id: "laptop".into(), + cwd: PathBuf::from("/tmp"), + command: "bash".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }, + }; + let kill = DaemonInbound::KillSession { + reply_to, + id: Uuid::from_u128(42), + }; + let ack = DaemonInbound::Ack; + let err = DaemonInbound::Error(ErrorInfo { + code: "unknown_session".into(), + message: "no such id".into(), + }); + for v in [create, kill, ack, err] { + assert_eq!(v, round_trip(&v)); + } + } + + #[test] + fn daemon_inbound_ack_tag() { + let v = serde_json::to_value(DaemonInbound::Ack).unwrap(); + assert_eq!(v, serde_json::json!({ "type": "Ack" })); + } + + #[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)); + } + } + + #[test] + fn unknown_message_type_is_rejected() { + let bad = serde_json::json!({ "type": "Bogus" }); + let err = serde_json::from_value::(bad).unwrap_err(); + assert!(err.to_string().contains("Bogus"), "{}", err); + } +}