This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-protocol/src/client.rs
T
2026-08-24 11:35:08 +01:00

242 lines
8.2 KiB
Rust

//! Typed client for the daemon's `/api/*` surface.
//!
//! Behind the `client` feature so consumers that only need the wire types and
//! route paths — `maestro-web`, `maestro-config` — don't pull an HTTP stack.
//!
//! This is the one place that knows how to call the daemon: the CLI, the TUI and
//! the end-to-end harness all go through it.
use std::time::Duration;
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Total budget for a single request. Bounds the case where the daemon is
/// TCP-alive but never makes forward progress.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
/// The daemon answered with a structured [`ApiError`] envelope.
#[error("daemon returned {status} ({}): {}", .info.code, .info.message)]
Api { status: StatusCode, info: ErrorInfo },
/// A non-success response whose body was not an [`ApiError`].
#[error("daemon returned {status}: {body}")]
Status { status: StatusCode, body: String },
#[error("decode: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
Url(#[from] UrlError),
}
impl ClientError {
/// True when the daemon could not be reached at all, rather than reaching it
/// and being refused. Drives "is the daemon running?" messaging.
pub fn is_unreachable(&self) -> bool {
matches!(self, ClientError::Http(e) if e.is_connect())
}
/// The daemon's own message when it sent one, so callers can render the
/// reason rather than a bare status code.
pub fn daemon_message(&self) -> Option<&str> {
match self {
ClientError::Api { info, .. } => Some(&info.message),
_ => None,
}
}
}
/// HTTP client bound to one daemon's base URL.
///
/// The daemon's surface needs no auth — reaching its bind address is the
/// authorization, same trust model as tmux itself.
#[derive(Debug, Clone)]
pub struct DaemonClient {
base: String,
http: reqwest::Client,
}
impl DaemonClient {
/// Client with the standard timeouts.
pub fn new(base: impl Into<String>) -> Self {
Self::with_timeouts(base, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT)
}
/// Client with explicit timeouts, for callers with a tighter budget than the
/// default — the TUI's startup health probe wants to fail fast rather than
/// hold the terminal for 30 seconds.
pub fn with_timeouts(base: impl Into<String>, connect: Duration, request: Duration) -> Self {
let http = reqwest::Client::builder()
.user_agent(concat!("maestro/", env!("CARGO_PKG_VERSION")))
.connect_timeout(connect)
.timeout(request)
.build()
.expect("build reqwest client");
Self {
base: routes::normalize_base(&base.into()).to_string(),
http,
}
}
pub fn base(&self) -> &str {
&self.base
}
/// `WS /api/events` URL for this daemon.
pub fn events_url(&self) -> Result<String, ClientError> {
Ok(routes::events_url(&self.base)?)
}
/// `WS /api/attach/:id` URL for this daemon.
pub fn attach_url(&self, id: Uuid) -> Result<String, ClientError> {
Ok(routes::attach_url(&self.base, id)?)
}
// -- typed calls --------------------------------------------------------
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
self.get(routes::SESSIONS, &[]).await
}
pub async fn create_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
self.post(routes::SESSIONS, req).await
}
/// Kill a session. With `force`, the daemon overrides the kill refusal
/// (#79) and retains the worktree instead of removing it (#81); the
/// outcome then names what was kept and where.
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
let query: &[(&str, &str)] = if force {
&[(routes::FORCE_QUERY, "true")]
} else {
&[]
};
let resp = self
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
.await?;
let resp = check(resp).await?;
// An unforced kill keeps its historical `204 No Content`.
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(KillOutcome { retained: None });
}
json(resp).await
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
pub async fn list_dir(&self, path: &str) -> Result<DirListing, ClientError> {
self.get(routes::DIRS, &[(routes::PATH_QUERY, path)]).await
}
pub async fn recent_dirs(&self) -> Result<RecentDirs, ClientError> {
self.get(routes::RECENT_DIRS, &[]).await
}
pub async fn git_root(&self, path: &str) -> Result<GitRoot, ClientError> {
self.get(routes::GITROOT, &[(routes::PATH_QUERY, path)])
.await
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
self.get(routes::PROJECTS, &[]).await
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
.await
}
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
/// breakdown as of this read.
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
self.get(routes::WORKTREES, &[]).await
}
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
/// daemon removes the directory and drops the store entry; the branch is
/// never deleted.
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, ClientError> {
let resp = self.send(Method::GET, path, query, None::<&()>).await?;
json(check(resp).await?).await
}
async fn post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ClientError> {
let resp = self.send(Method::POST, path, &[], Some(body)).await?;
json(check(resp).await?).await
}
async fn send<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
) -> Result<Response, ClientError> {
let mut req = self
.http
.request(method, routes::http_url(&self.base, path));
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(b);
}
Ok(req.send().await?)
}
}
/// Turn a non-success response into the richest error its body supports: the
/// daemon's [`ApiError`] envelope when present, the raw body otherwise.
async fn check(resp: Response) -> Result<Response, ClientError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<ApiError>(&body) {
Ok(api) => Err(ClientError::Api {
status,
info: api.error,
}),
Err(_) => Err(ClientError::Status { status, body }),
}
}
async fn json<T: DeserializeOwned>(resp: Response) -> Result<T, ClientError> {
// `204 No Content` is a valid success for the calls that return `()`; the
// typed getters never hit it.
Ok(resp.json::<T>().await?)
}