A Structured session's adapter hands back an ACP session id from session/new; a respawn will pass it to session/load (#135). This is a new field rather than #161's agent_session_id: that one is the Claude Code conversation id the activity hook reports and `claude --resume` takes, minted by Claude Code itself. The ACP id is minted by the adapter, only exists for Structured sessions, and is what the adapter takes back. Overloading one field with two ids from two processes would leave a respawn unable to tell which kind it holds.
1305 lines
50 KiB
Rust
1305 lines
50 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 display;
|
|
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,
|
|
/// Short human-readable string an agent reported alongside its activity
|
|
/// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line
|
|
/// while the report is in force; cleared when the report lapses or is
|
|
/// replaced. `None` unless an agent reported one.
|
|
#[serde(default)]
|
|
pub activity_detail: Option<String>,
|
|
/// 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>,
|
|
/// Wall-clock time at which the session entered its current
|
|
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
|
|
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
|
|
/// tracks the last observed PTY output. Populated by #104; defaults to the
|
|
/// Unix epoch on payloads from daemons that predate the field.
|
|
#[serde(default = "epoch")]
|
|
pub activity_since: 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>,
|
|
/// Wall-clock time this session last became the pane a client was
|
|
/// showing (#145) — stamped by both `POST /.../focus` and opening
|
|
/// `/api/attach/:id`, since either one is the operator moving there.
|
|
/// `None` until the session has been focused at least once, which is
|
|
/// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`)
|
|
/// sorts a never-focused session after every session that has one.
|
|
#[serde(default)]
|
|
pub last_focused_at: Option<DateTime<Utc>>,
|
|
/// Whether this session's process is a plain terminal (PTY bytes through
|
|
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
|
|
/// spawn time and never changes for the life of the session. Defaults to
|
|
/// `Terminal` on the wire so every existing client and payload from a
|
|
/// daemon that predates the field keeps working untouched.
|
|
#[serde(default)]
|
|
pub view: SessionView,
|
|
/// The ACP session id the `claude-code-acp` adapter handed back from
|
|
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
|
|
/// one, and only once its handshake succeeded; a later respawn passes it
|
|
/// to `session/load` so the conversation continues (#135). Distinct from
|
|
/// the hook-reported Claude Code conversation id (#161): that one is what
|
|
/// `claude --resume` takes, this one is what the ACP adapter takes, and
|
|
/// the two are minted by different processes with different lifetimes.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub acp_session_id: Option<String>,
|
|
}
|
|
|
|
/// A session's process shape (#133): a plain terminal, or a structured agent
|
|
/// driven over ACP rows. Chosen at spawn and immutable for the session's
|
|
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
|
|
/// written before this field existed meant a terminal session.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub enum SessionView {
|
|
#[default]
|
|
Terminal,
|
|
Structured,
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
|
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
|
/// whole `Session`. Non-string values still error — only variant names are
|
|
/// forgiven.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, 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,
|
|
/// The agent running in the session reported a failure (#102). Never
|
|
/// inferred by the daemon; only set when an agent reports it (#105).
|
|
Error,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for ActivityState {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
Ok(match s.as_str() {
|
|
"Active" => Self::Active,
|
|
"AwaitingInput" => Self::AwaitingInput,
|
|
"Error" => Self::Error,
|
|
// Known or not, any other spelling degrades to the neutral default
|
|
_ => Self::Idle,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
|
|
/// its own state. The daemon holds the reported state above its own inference
|
|
/// for a fixed TTL, then resumes inference.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ReportActivityRequest {
|
|
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
|
|
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
|
|
/// degrades an unrecognised state to `Idle` instead of dropping the whole
|
|
/// session. A report travelling the other way has no such excuse: an agent
|
|
/// posting a state this daemon does not know is a bug in the agent, and
|
|
/// silently recording it as `Idle` would hide that behind a 204.
|
|
#[serde(deserialize_with = "strict_activity_state")]
|
|
pub state: ActivityState,
|
|
/// Short human string shown next to the state (e.g. an error message).
|
|
/// The daemon caps its length server-side.
|
|
#[serde(default)]
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
|
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
|
|
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de::Error as _;
|
|
let s = String::deserialize(deserializer)?;
|
|
match s.as_str() {
|
|
"Active" => Ok(ActivityState::Active),
|
|
"Idle" => Ok(ActivityState::Idle),
|
|
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
|
"Error" => Ok(ActivityState::Error),
|
|
other => Err(D::Error::unknown_variant(
|
|
other,
|
|
&["Active", "Idle", "AwaitingInput", "Error"],
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// What a kill refusal reports about a worktree (#80) — the breakdown that
|
|
/// blocks an unforced kill, recomputed on every probe so it reflects the tree
|
|
/// now.
|
|
#[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,
|
|
/// Lines added across the tracked changes — staged and unstaged alike,
|
|
/// measured against `HEAD`. Untracked files contribute nothing: counting
|
|
/// their lines means reading every one off disk, and they already have
|
|
/// their own count above (docs/SIDEBAR.md).
|
|
#[serde(default)]
|
|
pub insertions: usize,
|
|
/// Lines removed across the same tracked changes.
|
|
#[serde(default)]
|
|
pub deletions: usize,
|
|
}
|
|
|
|
impl InTheWay {
|
|
/// Nothing blocks any more — the work has since been committed and pushed.
|
|
///
|
|
/// The line counts are not consulted: they describe the same tracked
|
|
/// changes `uncommitted_files` counts, and a change with no line delta —
|
|
/// a mode change, a rename — still blocks.
|
|
pub fn is_clear(&self) -> bool {
|
|
self.uncommitted_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
|
|
}
|
|
}
|
|
|
|
/// Which fragment of the compact blocked-work stat a value is, and so which
|
|
/// colour the client paints it (docs/SIDEBAR.md).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum StatKind {
|
|
/// Lines added.
|
|
Added,
|
|
/// Lines removed.
|
|
Removed,
|
|
/// Changed tracked files, when the change carries no line delta.
|
|
Files,
|
|
/// Untracked files.
|
|
Untracked,
|
|
/// Unpushed commits.
|
|
Unpushed,
|
|
}
|
|
|
|
impl InTheWay {
|
|
/// The breakdown as diff notation: `+124`, `-18`, `?3`, `↑2`.
|
|
///
|
|
/// The line counts cover tracked changes only, so a tree whose only
|
|
/// tracked change carries no line delta — a mode change, a rename —
|
|
/// reports the file count (`~2`) rather than claiming `+0 -0`. Both
|
|
/// sidebars render exactly these fragments; [`Display`](std::fmt::Display)
|
|
/// remains the spelled-out phrasing for the kill refusal and the tooltip.
|
|
pub fn stats(&self) -> Vec<(StatKind, String)> {
|
|
let mut out = Vec::new();
|
|
if self.insertions > 0 || self.deletions > 0 {
|
|
if self.insertions > 0 {
|
|
out.push((StatKind::Added, format!("+{}", self.insertions)));
|
|
}
|
|
if self.deletions > 0 {
|
|
out.push((StatKind::Removed, format!("-{}", self.deletions)));
|
|
}
|
|
} else if self.uncommitted_files > 0 {
|
|
out.push((StatKind::Files, format!("~{}", self.uncommitted_files)));
|
|
}
|
|
if self.untracked_files > 0 {
|
|
out.push((StatKind::Untracked, format!("?{}", self.untracked_files)));
|
|
}
|
|
if self.unpushed_commits > 0 {
|
|
out.push((StatKind::Unpushed, format!("↑{}", self.unpushed_commits)));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
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>,
|
|
/// The process shape to spawn (#133). Defaulted so every existing client
|
|
/// and `curl` against the API keeps requesting a `Terminal` session
|
|
/// untouched.
|
|
#[serde(default)]
|
|
pub view: SessionView,
|
|
}
|
|
|
|
/// 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 the browser SPA uses it to recover the rejection reason from a
|
|
/// failed request or 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,
|
|
}
|
|
|
|
/// Build provenance of the running daemon: the commit it was compiled
|
|
/// from and when that commit was authored.
|
|
///
|
|
/// Clients show it in the status indicator's tooltip, and compare `commit`
|
|
/// across a reconnect to tell an upgrade apart from a plain restart — an
|
|
/// upgrade means the served assets changed, so a browser reloads rather than
|
|
/// resuming against new code with an old bundle.
|
|
///
|
|
/// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git
|
|
/// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE`
|
|
/// environment variables when there is no checkout (the Nix build). Every field
|
|
/// is empty when neither is available; clients must render that gracefully.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BuildInfo {
|
|
/// Abbreviated commit hash the binary was built from. Empty if unknown.
|
|
#[serde(default)]
|
|
pub commit: String,
|
|
/// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty
|
|
/// if unknown.
|
|
#[serde(default)]
|
|
pub commit_date: String,
|
|
/// Whether the working tree carried uncommitted changes at build time.
|
|
#[serde(default)]
|
|
pub dirty: bool,
|
|
}
|
|
|
|
impl BuildInfo {
|
|
/// Whether anything is known about this build. A client with `false` here
|
|
/// shows the indicator without a release line rather than an empty one.
|
|
pub fn is_known(&self) -> bool {
|
|
!self.commit.is_empty()
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
/// Which commit this daemon was built from.
|
|
#[serde(default)]
|
|
pub build: BuildInfo,
|
|
/// Identity of this daemon *process*, regenerated on every start. Two
|
|
/// replies carrying different ids mean the daemon restarted in between —
|
|
/// which is how a client that lost its socket tells a restart from a
|
|
/// network blip, even when the build did not change.
|
|
#[serde(default)]
|
|
pub instance: String,
|
|
/// Canonical leader key the operator configured (`[tui].leader`, default
|
|
/// `ctrl-a`), served so the web client binds the same chord the TUI does
|
|
/// instead of its own hardcoded constant (#159). Empty from a daemon too
|
|
/// old to serve it, which clients read as "use your default".
|
|
#[serde(default)]
|
|
pub leader: String,
|
|
}
|
|
|
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
|
/// one-word session handles generated by the `maestro-names` crate.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct NameBatch {
|
|
pub names: Vec<String>,
|
|
}
|
|
|
|
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
|
|
/// pane, as if the operator had typed it on an attached terminal.
|
|
///
|
|
/// `submit` presses Enter after the text — what makes `maestro send` a way to
|
|
/// give an agent an instruction rather than only to fill its prompt. It goes
|
|
/// out as its own write a beat later, because a TUI agent that reads the text
|
|
/// as one paste would otherwise swallow the newline into the message.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SendInputRequest {
|
|
pub text: String,
|
|
#[serde(default = "submit_default")]
|
|
pub submit: bool,
|
|
}
|
|
|
|
fn submit_default() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Metadata key holding a session's name (#125).
|
|
pub const NAME_KEY: &str = "name";
|
|
|
|
/// Metadata key holding where that name came from (#131).
|
|
pub const NAME_SOURCE_KEY: &str = "name_source";
|
|
|
|
/// Where a session's [`NAME_KEY`] came from (#131), and so whether a client
|
|
/// renders it as settled or provisional.
|
|
///
|
|
/// Deserializes leniently, like [`ActivityState`]: an unknown spelling from a
|
|
/// newer daemon reads as [`NameSource::User`], the settled variant. A name
|
|
/// wrongly painted as settled is only a missed dimming; one wrongly painted
|
|
/// as provisional invites the daemon to overwrite a name a person chose.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
|
pub enum NameSource {
|
|
/// Typed by a person — in the spawn form, or through a rename. Never
|
|
/// overwritten by autonaming. The forward-compatible default, so a
|
|
/// session named by a client that predates this field is left alone.
|
|
#[default]
|
|
User,
|
|
/// The daemon's own random handle, assigned because the spawn request
|
|
/// carried no name. Provisional: rendered dimmed, and the only source
|
|
/// autonaming will replace.
|
|
Generated,
|
|
/// Derived from the session's first prompt (#131). Settled — a second
|
|
/// prompt does not rename the session again.
|
|
Haiku,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for NameSource {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s = String::deserialize(deserializer)?;
|
|
Ok(Self::from_wire(&s))
|
|
}
|
|
}
|
|
|
|
impl NameSource {
|
|
/// Read a stored spelling, leniently: anything unrecognised is
|
|
/// [`NameSource::User`].
|
|
pub fn from_wire(s: &str) -> Self {
|
|
match s {
|
|
"Generated" => Self::Generated,
|
|
"Haiku" => Self::Haiku,
|
|
_ => Self::User,
|
|
}
|
|
}
|
|
|
|
/// The spelling stored in `metadata`.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::User => "User",
|
|
Self::Generated => "Generated",
|
|
Self::Haiku => "Haiku",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// HTTP `POST /api/sessions/:id/prompt` body (#131): the first prompt an
|
|
/// agent running in the session received. Agent-agnostic — Claude Code posts
|
|
/// it from a `UserPromptSubmit` hook, and any other agent's hook writes the
|
|
/// same shape.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SessionPromptRequest {
|
|
pub prompt: String,
|
|
}
|
|
|
|
/// HTTP `POST /api/sessions/:id/focus` body: a client reports whether its
|
|
/// pane is the one currently showing this session — not merely highlighted
|
|
/// in a sidebar. The daemon keeps one focus clock per session, last write
|
|
/// wins; a second client attached to the same session can race the first
|
|
/// one's unfocus, which is accepted as a rare, harmless case (worst outcome
|
|
/// is one extra tick before the glow clears).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SetFocusRequest {
|
|
pub focused: bool,
|
|
}
|
|
|
|
/// Longest session name the daemon stores (#125), in characters. A name is a
|
|
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
|
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
|
|
|
/// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RenameSessionRequest {
|
|
pub name: String,
|
|
}
|
|
|
|
/// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`]
|
|
/// characters. `None` when nothing is left — that clears `metadata.name` and
|
|
/// the card falls back to the command, the same rule the spawn form uses when
|
|
/// its name field is blank.
|
|
///
|
|
/// Shared so the daemon and every Rust client agree on what a name becomes;
|
|
/// `web/src/types.ts` carries the same function for the browser.
|
|
pub fn normalize_session_name(name: &str) -> Option<String> {
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"));
|
|
}
|
|
|
|
/// docs/SIDEBAR.md: the sidebars render the breakdown as diff notation.
|
|
#[test]
|
|
fn stats_read_as_a_diff() {
|
|
let w = InTheWay {
|
|
uncommitted_files: 3,
|
|
untracked_files: 2,
|
|
unpushed_commits: 1,
|
|
insertions: 124,
|
|
deletions: 18,
|
|
};
|
|
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
|
|
assert_eq!(text, ["+124", "-18", "?2", "↑1"]);
|
|
let kinds: Vec<StatKind> = w.stats().into_iter().map(|(k, _)| k).collect();
|
|
assert_eq!(
|
|
kinds,
|
|
[
|
|
StatKind::Added,
|
|
StatKind::Removed,
|
|
StatKind::Untracked,
|
|
StatKind::Unpushed
|
|
]
|
|
);
|
|
}
|
|
|
|
/// A change with no line delta — a mode change — still has to say
|
|
/// something, and `+0 -0` would be a lie about the tree.
|
|
#[test]
|
|
fn stats_fall_back_to_the_file_count_without_a_line_delta() {
|
|
let w = InTheWay {
|
|
uncommitted_files: 2,
|
|
..InTheWay::default()
|
|
};
|
|
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
|
|
assert_eq!(text, ["~2"]);
|
|
assert!(!w.is_clear());
|
|
}
|
|
|
|
/// A clear tree says nothing at all — the fragment is omitted, not empty.
|
|
#[test]
|
|
fn a_clear_tree_has_no_stats() {
|
|
assert!(InTheWay::default().stats().is_empty());
|
|
assert!(InTheWay::default().is_clear());
|
|
}
|
|
|
|
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,
|
|
activity_detail: None,
|
|
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc),
|
|
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc),
|
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
|
blocked_work: None,
|
|
last_focused_at: None,
|
|
view: SessionView::Terminal,
|
|
acp_session_id: 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 last_focused_at_defaults_to_none_on_legacy_payload() {
|
|
// Pre-#145 payload omits the field; `None` means never-focused.
|
|
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.last_focused_at, None);
|
|
|
|
let mut focused = sample_session();
|
|
focused.last_focused_at = Some(
|
|
DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc),
|
|
);
|
|
assert_eq!(focused, round_trip(&focused));
|
|
}
|
|
|
|
#[test]
|
|
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
|
// A daemon predating #102 omits `activity_since`; it must deserialize
|
|
// to the forward-compat default 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.activity_since, epoch());
|
|
|
|
let full = sample_session();
|
|
assert_eq!(full, round_trip(&full));
|
|
}
|
|
|
|
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
|
|
#[test]
|
|
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
|
|
assert_eq!(
|
|
serde_json::to_value(ActivityState::Error).unwrap(),
|
|
serde_json::json!("Error")
|
|
);
|
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
// #110 / SIDEBAR.md Precedence: a future daemon may send an
|
|
// `ActivityState` variant this client does not know; it must degrade to
|
|
// the neutral default instead of failing the whole `Session`.
|
|
#[test]
|
|
fn unknown_activity_state_variant_deserializes_to_idle() {
|
|
assert_eq!(
|
|
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
|
|
ActivityState::Idle
|
|
);
|
|
|
|
let mut future = serde_json::to_value(sample_session()).unwrap();
|
|
future["activity"] = serde_json::json!("QuantumEntangled");
|
|
let s: Session = serde_json::from_value(future).unwrap();
|
|
assert_eq!(s.activity, ActivityState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn non_string_activity_payload_still_fails_deserialization() {
|
|
let json = serde_json::json!({
|
|
"id": Uuid::from_u128(1),
|
|
"cwd": "/tmp",
|
|
"command": "bash",
|
|
"status": "Running",
|
|
"created_at": "2026-05-24T12:00:00Z",
|
|
"activity": 42,
|
|
});
|
|
assert!(serde_json::from_value::<Session>(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn activity_detail_defaults_to_none_on_legacy_payload() {
|
|
// #105: a daemon predating the detail field omits it; 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.activity_detail, None);
|
|
|
|
let mut reported = sample_session();
|
|
reported.activity = ActivityState::Error;
|
|
reported.activity_detail = Some("build failed".into());
|
|
assert_eq!(reported.clone(), round_trip(&reported));
|
|
}
|
|
|
|
#[test]
|
|
fn report_activity_request_defaults_detail_and_rejects_unknown_state() {
|
|
let bare: ReportActivityRequest =
|
|
serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap();
|
|
assert_eq!(bare.state, ActivityState::Error);
|
|
assert_eq!(bare.detail, None);
|
|
|
|
let full = ReportActivityRequest {
|
|
state: ActivityState::AwaitingInput,
|
|
detail: Some("waiting on permission".into()),
|
|
};
|
|
assert_eq!(full, round_trip(&full));
|
|
|
|
// Unknown state values are rejected rather than coerced (#105).
|
|
assert!(
|
|
serde_json::from_value::<ReportActivityRequest>(serde_json::json!({
|
|
"state": "Teleported"
|
|
}))
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
}),
|
|
view: SessionView::Terminal,
|
|
};
|
|
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());
|
|
assert_eq!(req.view, SessionView::Terminal);
|
|
}
|
|
|
|
#[test]
|
|
fn session_view_defaults_to_terminal_on_legacy_payload() {
|
|
// A pre-#133 daemon or client omits `view`; it must deserialize to
|
|
// `Terminal` rather than failing, and `Structured` 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.view, SessionView::Terminal);
|
|
|
|
let mut structured = sample_session();
|
|
structured.view = SessionView::Structured;
|
|
assert_eq!(structured, round_trip(&structured));
|
|
}
|
|
|
|
#[test]
|
|
fn acp_session_id_defaults_to_none_and_round_trips() {
|
|
// A payload from before #134 carries no `acp_session_id`; it must
|
|
// read as `None`, stay off the wire while unset, and round-trip
|
|
// once the adapter has handed one back.
|
|
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.acp_session_id, None);
|
|
let serialized = serde_json::to_value(&s).unwrap();
|
|
assert!(serialized.get("acp_session_id").is_none());
|
|
|
|
let mut structured = sample_session();
|
|
structured.view = SessionView::Structured;
|
|
structured.acp_session_id = Some("sess_01HZX".into());
|
|
assert_eq!(structured, round_trip(&structured));
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|
|
|
|
/// #125: a rename request is trimmed and capped; a blank one clears the
|
|
/// name so the card falls back to the command.
|
|
#[test]
|
|
fn session_names_are_trimmed_capped_and_blankable() {
|
|
assert_eq!(
|
|
normalize_session_name(" amber-falcon "),
|
|
Some("amber-falcon".into())
|
|
);
|
|
assert_eq!(normalize_session_name(""), None);
|
|
assert_eq!(normalize_session_name(" \t "), None);
|
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 10);
|
|
assert_eq!(
|
|
normalize_session_name(&long).unwrap().chars().count(),
|
|
MAX_SESSION_NAME_LEN
|
|
);
|
|
}
|
|
|
|
/// The cap counts characters, not bytes — a multi-byte name must not be
|
|
/// sliced mid-codepoint.
|
|
#[test]
|
|
fn session_name_cap_counts_characters() {
|
|
let name = "é".repeat(MAX_SESSION_NAME_LEN + 5);
|
|
let capped = normalize_session_name(&name).unwrap();
|
|
assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN);
|
|
}
|
|
|
|
/// #131: the prompt body round-trips whatever the agent sent, quotes and
|
|
/// newlines included — the hook posts its payload verbatim rather than
|
|
/// building JSON in shell.
|
|
#[test]
|
|
fn prompt_request_round_trips_awkward_text() {
|
|
let req = SessionPromptRequest {
|
|
prompt: "fix \"the\" parser\nthen run tests".into(),
|
|
};
|
|
assert_eq!(req, round_trip(&req));
|
|
}
|
|
|
|
/// The stored spelling and the parse of it agree, and anything else is
|
|
/// the settled default (#131).
|
|
#[test]
|
|
fn name_source_spellings_round_trip_and_degrade_to_user() {
|
|
for source in [NameSource::User, NameSource::Generated, NameSource::Haiku] {
|
|
assert_eq!(NameSource::from_wire(source.as_str()), source);
|
|
assert_eq!(
|
|
serde_json::to_value(source).unwrap(),
|
|
serde_json::Value::String(source.as_str().into())
|
|
);
|
|
assert_eq!(round_trip(&source), source);
|
|
}
|
|
assert_eq!(NameSource::from_wire("Rolled"), NameSource::User);
|
|
assert_eq!(NameSource::default(), NameSource::User);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_request_round_trips() {
|
|
let req = RenameSessionRequest {
|
|
name: "amber-falcon".into(),
|
|
};
|
|
assert_eq!(req, round_trip(&req));
|
|
assert_eq!(
|
|
serde_json::to_value(&req).unwrap(),
|
|
serde_json::json!({"name": "amber-falcon"})
|
|
);
|
|
}
|
|
}
|