326 lines
11 KiB
Rust
326 lines
11 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-daemon`, `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,
|
|
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
|
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
|
};
|
|
|
|
/// 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)?)
|
|
}
|
|
|
|
/// `WS /api/structured/:id` URL for this daemon.
|
|
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
|
|
Ok(routes::structured_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 leaves the worktree on disk instead of removing it (#81).
|
|
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<(), ClientError> {
|
|
let query: &[(&str, &str)] = if force {
|
|
&[(routes::FORCE_QUERY, "true")]
|
|
} else {
|
|
&[]
|
|
};
|
|
let resp = self
|
|
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
|
|
/// clears the custom one, so the session falls back to its command. The
|
|
/// daemon trims and caps the name and answers with the updated session.
|
|
pub async fn rename_session(&self, id: Uuid, name: &str) -> Result<Session, ClientError> {
|
|
let req = RenameSessionRequest { name: name.into() };
|
|
let resp = self
|
|
.send(Method::PATCH, &routes::session(id), &[], Some(&req))
|
|
.await?;
|
|
json(check(resp).await?).await
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
|
/// state. The daemon holds it above its own inference for a TTL, then
|
|
/// resumes inference. Answers `204 No Content`.
|
|
pub async fn report_activity(
|
|
&self,
|
|
id: Uuid,
|
|
req: &ReportActivityRequest,
|
|
) -> Result<(), ClientError> {
|
|
let resp = self
|
|
.send(Method::POST, &routes::session_activity(id), &[], Some(req))
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
|
|
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
|
|
/// session that has exited is refused rather than silently accepted.
|
|
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
|
|
let resp = self
|
|
.send(Method::POST, &routes::session_input(id), &[], Some(req))
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/focus`: report whether this client's pane is
|
|
/// the one currently showing `id` — not merely highlighted in a sidebar.
|
|
/// Answers `204 No Content`.
|
|
pub async fn set_focus(&self, id: Uuid, focused: bool) -> Result<(), ClientError> {
|
|
let req = SetFocusRequest { focused };
|
|
let resp = self
|
|
.send(Method::POST, &routes::session_focus(id), &[], Some(&req))
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Send one prompt to a structured session. The daemon rejects a second
|
|
/// prompt while the preceding ACP turn is still active.
|
|
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
|
|
let req = StructuredPromptRequest {
|
|
prompt: prompt.to_string(),
|
|
};
|
|
let resp = self
|
|
.send(
|
|
Method::POST,
|
|
&routes::structured_prompt(id),
|
|
&[],
|
|
Some(&req),
|
|
)
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Cancel the structured session's active ACP turn.
|
|
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
|
|
let resp = self
|
|
.send(
|
|
Method::POST,
|
|
&routes::structured_cancel(id),
|
|
&[],
|
|
None::<&()>,
|
|
)
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the currently pending structured-session permission request.
|
|
pub async fn resolve_structured_approval(
|
|
&self,
|
|
id: Uuid,
|
|
nonce: &str,
|
|
decision: StructuredApprovalDecision,
|
|
) -> Result<(), ClientError> {
|
|
let resp = self
|
|
.send(
|
|
Method::POST,
|
|
&routes::structured_approval(id, nonce),
|
|
&[],
|
|
Some(&decision),
|
|
)
|
|
.await?;
|
|
check(resp).await?;
|
|
Ok(())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// -- 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?)
|
|
}
|