//! 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) -> 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, 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 { Ok(routes::events_url(&self.base)?) } /// `WS /api/attach/:id` URL for this daemon. pub fn attach_url(&self, id: Uuid) -> Result { Ok(routes::attach_url(&self.base, id)?) } /// `WS /api/structured/:id` URL for this daemon. pub fn structured_url(&self, id: Uuid) -> Result { Ok(routes::structured_url(&self.base, id)?) } // -- typed calls -------------------------------------------------------- pub async fn list_sessions(&self) -> Result, ClientError> { self.get(routes::SESSIONS, &[]).await } pub async fn create_session(&self, req: &CreateSessionRequest) -> Result { 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 { 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 { self.get(routes::INFO, &[]).await } pub async fn list_dir(&self, path: &str) -> Result { self.get(routes::DIRS, &[(routes::PATH_QUERY, path)]).await } pub async fn recent_dirs(&self) -> Result { self.get(routes::RECENT_DIRS, &[]).await } pub async fn git_root(&self, path: &str) -> Result { self.get(routes::GITROOT, &[(routes::PATH_QUERY, path)]) .await } pub async fn list_projects(&self) -> Result, ClientError> { self.get(routes::PROJECTS, &[]).await } pub async fn list_branches(&self, path: &str) -> Result, ClientError> { self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)]) .await } // -- plumbing ----------------------------------------------------------- async fn get( &self, path: &str, query: &[(&str, &str)], ) -> Result { let resp = self.send(Method::GET, path, query, None::<&()>).await?; json(check(resp).await?).await } async fn post( &self, path: &str, body: &B, ) -> Result { let resp = self.send(Method::POST, path, &[], Some(body)).await?; json(check(resp).await?).await } async fn send( &self, method: Method, path: &str, query: &[(&str, &str)], body: Option<&B>, ) -> Result { 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 { let status = resp.status(); if status.is_success() { return Ok(resp); } let body = resp.text().await.unwrap_or_default(); match serde_json::from_str::(&body) { Ok(api) => Err(ClientError::Api { status, info: api.error, }), Err(_) => Err(ClientError::Status { status, body }), } } async fn json(resp: Response) -> Result { // `204 No Content` is a valid success for the calls that return `()`; the // typed getters never hit it. Ok(resp.json::().await?) }