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.
786 lines
29 KiB
Rust
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));
|
|
}
|
|
}
|
|
}
|