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<T> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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<String, String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub status: SessionStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String, String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
#[serde(default)]
|
||||
pub worktree: Option<WorktreeSpec>,
|
||||
}
|
||||
|
||||
/// 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<T> {
|
||||
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<Session>,
|
||||
},
|
||||
SessionAdded {
|
||||
session: Session,
|
||||
},
|
||||
SessionRemoved {
|
||||
id: Uuid,
|
||||
},
|
||||
SessionUpdated {
|
||||
session: Session,
|
||||
},
|
||||
Heartbeat,
|
||||
CreateSessionResult {
|
||||
reply_to: ReplyTo,
|
||||
result: RpcResult<Session>,
|
||||
},
|
||||
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<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),
|
||||
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<Session> = RpcResult::Ok {
|
||||
value: sample_session(),
|
||||
};
|
||||
let err: RpcResult<Session> = 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::<DaemonOutbound>(bad).unwrap_err();
|
||||
assert!(err.to_string().contains("Bogus"), "{}", err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user