Register a structured session immediately with `activity: Starting` and run the ACP handshake in a background task. The HTTP caller gets an instant response; the user can begin typing a prompt while the adapter boots. - Add `ActivityState::Starting` to the protocol - Open the event log eagerly so the structured WS can connect during boot (empty transcript + idle state) - `queue_acp_prompt` stashes a prompt while `Starting`; the background task drains it on successful handshake - `apply_structured_activity` skips overwriting `Starting` - `subscribe_acp_projection` allows connecting during `Starting` - TUI/CLI map `Starting` to idle colour / "starting" label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1489 lines
56 KiB
Rust
1489 lines
56 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 selected provider 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,
|
|
}
|
|
|
|
/// Session activity, orthogonal to [`SessionStatus`] (NAP-340). Terminal
|
|
/// sessions use pane/process inference and agent reports; structured sessions
|
|
/// use their ACP turn lifecycle (docs/SIDEBAR.md).
|
|
///
|
|
/// 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, the foreground process is busy, or an ACP turn is active.
|
|
Active,
|
|
/// Foreground process alive but quiescent. The forward-compatible default.
|
|
#[default]
|
|
Idle,
|
|
/// Blocked on terminal input or waiting for an ACP approval.
|
|
AwaitingInput,
|
|
/// An agent reported a failure, or a structured session/turn failed.
|
|
Error,
|
|
/// The ACP adapter is booting; the session is registered but cannot accept
|
|
/// prompts yet. Older clients that do not know this variant degrade it to
|
|
/// `Idle` via the lenient deserializer — acceptable.
|
|
Starting,
|
|
}
|
|
|
|
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,
|
|
"Starting" => Self::Starting,
|
|
// 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),
|
|
"Starting" => Ok(ActivityState::Starting),
|
|
other => Err(D::Error::unknown_variant(
|
|
other,
|
|
&["Active", "Idle", "AwaitingInput", "Error", "Starting"],
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// When `true`, the daemon auto-approves every ACP permission request
|
|
/// for this structured session.
|
|
#[serde(default)]
|
|
pub auto_approve: bool,
|
|
}
|
|
|
|
/// 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,
|
|
/// When `true`, the daemon auto-approves every ACP permission request
|
|
/// for structured sessions spawned from this preset instead of surfacing
|
|
/// them to the client.
|
|
#[serde(default)]
|
|
pub auto_approve: bool,
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structured-session projections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One client-neutral item in a structured session transcript.
|
|
///
|
|
/// `data` preserves the ACP payload without committing clients to an ACP
|
|
/// dependency. Rendering choices, including markdown and tool-card treatment,
|
|
/// stay with the client.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct TranscriptRow {
|
|
/// Stable within one session, so later deltas can address this row.
|
|
pub id: String,
|
|
pub kind: TranscriptRowKind,
|
|
pub data: Value,
|
|
}
|
|
|
|
/// The semantic shape of a [`TranscriptRow`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TranscriptRowKind {
|
|
User,
|
|
AgentText,
|
|
AgentThought,
|
|
ToolCall,
|
|
ToolDiff,
|
|
Approval,
|
|
Plan,
|
|
Notice,
|
|
}
|
|
|
|
/// A complete transcript at a durable event-log position.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct TranscriptSnapshot {
|
|
pub cursor: u64,
|
|
#[serde(default)]
|
|
pub rows: Vec<TranscriptRow>,
|
|
}
|
|
|
|
/// One durable, client-neutral replay step from a structured session.
|
|
///
|
|
/// Results are ordered by increasing `cursor`. `since` returns the first
|
|
/// matching steps; `before` returns the final matching steps, still increasing.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct StructuredReplayEvent {
|
|
/// Durable ACP-log position represented by this projected step.
|
|
pub cursor: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub transcript: Option<TranscriptDelta>,
|
|
pub state: ReducedState,
|
|
}
|
|
|
|
/// A change to a transcript row.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum TranscriptDelta {
|
|
Append { row: TranscriptRow },
|
|
Patch { row: TranscriptRow },
|
|
Remove { id: String },
|
|
}
|
|
|
|
/// Per-turn indicators derived from the ACP stream.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TurnState {
|
|
#[serde(default)]
|
|
pub responding: bool,
|
|
#[serde(default)]
|
|
pub thinking: bool,
|
|
#[serde(default)]
|
|
pub active_tool_calls: Vec<String>,
|
|
/// Wall-clock start of the active prompt. This lets reconnecting clients
|
|
/// keep one continuous elapsed-time display instead of restarting at zero.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub started_at: Option<DateTime<Utc>>,
|
|
/// Context-window usage when the active prompt began. ACP exposes context
|
|
/// occupancy, not provider billing-token accounting, so clients must label
|
|
/// deltas as context tokens rather than tokens billed.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub started_context_tokens: Option<u64>,
|
|
}
|
|
|
|
/// A whole-session view derived from ACP notifications.
|
|
///
|
|
/// Values whose shape ACP owns are carried as JSON so this wire protocol stays
|
|
/// forward-compatible when ACP adds fields or variants.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct ReducedState {
|
|
#[serde(default)]
|
|
pub turn: TurnState,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub pending_approval: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub plan: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub usage: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub context: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mode: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub config_options: Option<Value>,
|
|
}
|
|
|
|
/// Server messages sent over a structured-session WebSocket.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum StructuredEvent {
|
|
TranscriptSnapshot {
|
|
snapshot: TranscriptSnapshot,
|
|
},
|
|
ReducedState {
|
|
state: ReducedState,
|
|
},
|
|
ProjectionUpdate {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
transcript: Option<TranscriptDelta>,
|
|
state: ReducedState,
|
|
},
|
|
}
|
|
|
|
/// HTTP body for `POST /api/sessions/:id/structured/prompt`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct StructuredPromptRequest {
|
|
pub prompt: String,
|
|
}
|
|
|
|
/// HTTP body for `POST /api/sessions/:id/structured/config`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct StructuredConfigRequest {
|
|
pub config_id: String,
|
|
pub value: Value,
|
|
}
|
|
|
|
/// Operator decision for a pending ACP permission request.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum StructuredApprovalDecision {
|
|
AllowOnce,
|
|
AllowForSession,
|
|
Deny,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
auto_approve: false,
|
|
};
|
|
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 structured_projection_types_round_trip_and_default() {
|
|
let row = TranscriptRow {
|
|
id: "tool:read-1".into(),
|
|
kind: TranscriptRowKind::ToolCall,
|
|
data: serde_json::json!({"name": "vendor.read", "title": "Read"}),
|
|
};
|
|
let snapshot = TranscriptSnapshot {
|
|
cursor: 7,
|
|
rows: vec![row.clone()],
|
|
};
|
|
assert_eq!(snapshot, round_trip(&snapshot));
|
|
assert_eq!(
|
|
TranscriptDelta::Patch { row: row.clone() },
|
|
round_trip(&TranscriptDelta::Patch { row })
|
|
);
|
|
|
|
let state: ReducedState = serde_json::from_value(serde_json::json!({})).unwrap();
|
|
assert_eq!(state, ReducedState::default());
|
|
assert_eq!(
|
|
serde_json::to_value(TranscriptRowKind::AgentThought).unwrap(),
|
|
serde_json::json!("agent_thought")
|
|
);
|
|
}
|
|
|
|
#[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"})
|
|
);
|
|
}
|
|
}
|