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
Miguel Palhas 003e3839bc feat(maestro): carry a blocked-work indicator on Session (#83)
Session.blocked_work is Option<InTheWay>, refreshed by the daemon's
watcher; None for worktree-less sessions and until first probed.
Defaults on the wire so older payloads stay forward-compatible.
2026-08-24 12:58:16 +01:00

786 lines
29 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>,
/// The worktree this session owns, when it was spawned with one.
///
/// Persisted rather than kept in memory only: after a daemon restart the
/// recovered session must still know what to clean up, and with
/// deterministic worktree paths a forgotten association leaves a directory
/// that blocks that branch from ever being spawned again (#54 review).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<SessionWorktree>,
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>,
/// Whether the session's worktree currently holds anything that would
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
/// Refreshed by the daemon's watcher; `None` for sessions without a
/// worktree and until the first probe. Defaults on the wire so payloads
/// from daemons that predate the field stay forward-compatible.
#[serde(default)]
pub blocked_work: Option<InTheWay>,
}
/// 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,
/// 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
}
/// The worktree a running session owns, carried on [`Session`] so it survives
/// a daemon restart.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionWorktree {
pub path: PathBuf,
pub base_repo: PathBuf,
}
/// Why a retained worktree outlived its session (#80). Same values the
/// daemon's retained store persists; new variants are additive but clients
/// should render unknown reasons generically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetainedReason {
/// The session was killed with `--force` while its worktree held unsaved
/// work.
ForceKilled,
/// The session ended by itself and its worktree held unsaved work.
ExitedWithUnsavedWork,
}
/// What a kill refusal would report about a worktree right now (#80) — the
/// same breakdown that blocks an unforced kill, recomputed on every read of
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
/// retained.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InTheWay {
/// Tracked files with uncommitted modifications.
#[serde(default)]
pub uncommitted_files: usize,
/// Untracked files that are not ignored (ignored files never block).
#[serde(default)]
pub untracked_files: usize,
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: usize,
}
impl InTheWay {
/// Nothing blocks any more — the work has since been committed and pushed.
pub fn is_clear(&self) -> bool {
*self == Self::default()
}
}
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
/// in-the-way state as of this read. Reclaiming it via
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedWorktree {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
#[serde(default)]
pub in_the_way: InTheWay,
}
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
/// A forced kill ends the session and retains its worktree instead of
/// removing it; this names what was kept, where, and why, so a client can say
/// where the work went rather than leaving the operator to wonder.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KillOutcome {
/// Set when the worktree was retained with reason `force-killed`; `None`
/// when there was nothing to retain — no worktree at all, or one whose
/// guard was clear, which a forced kill removes as usual.
#[serde(default)]
pub retained: Option<RetainedWorktree>,
}
impl std::fmt::Display for InTheWay {
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
/// all-zero renders "no unsaved work". This is the phrasing every client
/// uses for both the kill refusal and the retained list, so the two
/// cannot describe the same tree differently.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts = [
(self.uncommitted_files, "uncommitted file"),
(self.untracked_files, "untracked file"),
(self.unpushed_commits, "unpushed commit"),
];
let said: Vec<String> = parts
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, unit)| {
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
})
.collect();
if said.is_empty() {
return write!(f, "no unsaved work");
}
write!(f, "{}", said.join(", "))
}
}
/// 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>,
}
/// One discovered git repository (or linked worktree) for `GET /api/projects`
/// (#54). The daemon reports the timestamp; clients decide what "stale" means
/// (the web UI dims entries older than 7 days).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
/// Absolute path of the repository/worktree toplevel.
pub path: PathBuf,
/// Basename of `path`.
pub name: String,
#[serde(default)]
pub is_worktree: bool,
/// The main checkout this worktree belongs to. Set only when
/// [`Self::is_worktree`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_repo: Option<PathBuf>,
/// Later of the repo's git HEAD mtime and the newest spawn-history use of
/// any directory inside it. `None` when neither is known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<DateTime<Utc>>,
/// Command last spawned in this project, from spawn history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_command: Option<String>,
}
/// One local branch of a repository for `GET /api/branches` (#55). A client
/// disables the entry when `is_head` is set or `worktree` is present — git
/// refuses to check the same branch out in two worktrees at once.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchInfo {
/// Short branch name (`main`, `feature/foo`).
pub name: String,
/// Whether this branch is the main checkout's current HEAD. The main
/// checkout's occupancy reports here rather than through `worktree`.
pub is_head: bool,
/// Toplevel path of the linked worktree holding this branch, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: 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,
/// Set only by the kill guard (#79): the unsaved work that blocked
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unsaved_work: Option<InTheWay>,
}
/// 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(),
unsaved_work: None,
},
}
}
/// 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>,
}
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
/// `adjective-noun` session handles generated by the `maestro-names` crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NameBatch {
pub names: Vec<String>,
}
// ---------------------------------------------------------------------------
// 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")
}
#[test]
fn project_round_trips_and_defaults_on_legacy_payload() {
let full = Project {
path: PathBuf::from("/home/u/tea/maestro"),
name: "maestro".into(),
is_worktree: true,
base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")),
last_activity: Some(
DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
last_command: Some("claude".into()),
};
assert_eq!(full, round_trip(&full));
// A payload from a daemon predating the optional fields still decodes.
let legacy = serde_json::json!({
"path": "/home/u/proj",
"name": "proj",
});
let p: Project = serde_json::from_value(legacy).unwrap();
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert_eq!(p.last_activity, None);
assert_eq!(p.last_command, None);
}
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()))]),
worktree: None,
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")),
blocked_work: None,
}
}
#[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());
assert!(s.blocked_work.is_none());
}
#[test]
fn blocked_work_defaults_to_none_on_legacy_payload() {
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
// rather than failing, and a present value round-trips.
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_eq!(s.blocked_work, None);
let mut with_blocker = sample_session();
with_blocker.blocked_work = Some(InTheWay {
uncommitted_files: 3,
..InTheWay::default()
});
assert_eq!(
with_blocker.clone().blocked_work,
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
.unwrap()
.blocked_work
);
}
#[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(),
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);
}
#[test]
fn worktree_spec_round_trips_existing_branch_mode() {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
fn sample_retained_worktree() -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
base_repo: PathBuf::from("/work/maestro"),
branch: "feat/login".into(),
reason: RetainedReason::ForceKilled,
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
.unwrap()
.with_timezone(&Utc),
in_the_way: InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
#[test]
fn retained_worktree_round_trips() {
let wt = sample_retained_worktree();
assert_eq!(wt, round_trip(&wt));
}
/// A payload from a daemon predating `in_the_way` still decodes; the
/// breakdown defaults to "nothing in the way".
#[test]
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
let legacy = serde_json::json!({
"id": Uuid::from_u128(1),
"path": "/w/t",
"base_repo": "/w",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
});
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
assert_eq!(wt.reason, RetainedReason::ForceKilled);
assert_eq!(wt.in_the_way, InTheWay::default());
assert!(wt.in_the_way.is_clear());
}
#[test]
fn retained_reason_serializes_kebab_case() {
assert_eq!(
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
serde_json::json!("force-killed")
);
assert_eq!(
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
serde_json::json!("exited-with-unsaved-work")
);
}
#[test]
fn kill_outcome_round_trips_a_retained_worktree() {
let outcome = KillOutcome {
retained: Some(sample_retained_worktree()),
};
assert_eq!(outcome, round_trip(&outcome));
}
/// A kill with nothing to retain — no worktree, or a clean one removed as
/// usual — decodes from an absent field.
#[test]
fn kill_outcome_defaults_to_nothing_retained() {
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(outcome.retained, None);
}
#[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(),
unsaved_work: None,
}),
resize,
] {
assert_eq!(c, round_trip(&c));
}
}
}