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/routes.rs
T
Miguel Palhas 9dad3e6472
ci / web (push) Successful in 22s
ci / flake (push) Successful in 4m47s
ci / rust (push) Successful in 5m59s
Merge remote-tracking branch 'origin/main' into animato
# Conflicts:
#	crates/maestro-daemon/Cargo.toml
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro/Cargo.toml
2026-09-02 18:37:53 +01:00

263 lines
8.8 KiB
Rust

//! The daemon's `/api/*` surface: paths, and the scheme swap every client
//! needs to reach the WebSocket endpoints.
//!
//! Everything that knows what the daemon's URLs look like lives here.
use uuid::Uuid;
/// Prefix every daemon route shares. The daemon uses it to decide whether a
/// request hits the API or is served from the SPA bundle.
pub const API_PREFIX: &str = "/api";
/// `GET` list / `POST` spawn.
pub const SESSIONS: &str = "/api/sessions";
/// `GET` daemon identity, version, and spawn presets.
pub const INFO: &str = "/api/info";
/// `GET` one directory's immediate subdirectories. Takes a `path` query param.
pub const DIRS: &str = "/api/dirs";
/// `GET` spawn history.
pub const RECENT_DIRS: &str = "/api/recent-dirs";
/// `GET` discovered git repositories and worktrees, newest activity first.
pub const PROJECTS: &str = "/api/projects";
/// `GET` the enclosing repository of a path. Takes a `path` query param.
pub const GITROOT: &str = "/api/gitroot";
/// `GET` one repository's local branches. Takes a `path` query param.
pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take.
pub const PATH_QUERY: &str = "path";
/// Name of the query parameter [`NAMES`] takes.
pub const COUNT_QUERY: &str = "count";
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
/// `force=true` overrides the kill refusal and leaves the worktree on disk.
pub const FORCE_QUERY: &str = "force";
/// `DELETE /api/sessions/:id`.
pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity")
}
/// `POST /api/sessions/:id/prompt` (#131): an agent reports the prompt it was
/// just given, so the daemon can name the session after the first one.
pub fn session_prompt(id: Uuid) -> String {
format!("{SESSIONS}/{id}/prompt")
}
/// `POST /api/sessions/:id/input`: type text into a session's pane.
pub fn session_input(id: Uuid) -> String {
format!("{SESSIONS}/{id}/input")
}
/// `POST /api/sessions/:id/focus`: a client reports whether its pane is the
/// one currently showing this session.
pub fn session_focus(id: Uuid) -> String {
format!("{SESSIONS}/{id}/focus")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const SESSION_PROMPT: &str = "/api/sessions/:id/prompt";
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
pub const ATTACH: &str = "/api/attach/:id";
}
/// A daemon base URL that could not be turned into a WebSocket base.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UrlError {
#[error("daemon url has no supported scheme (want http/https/ws/wss): {0}")]
UnsupportedScheme(String),
}
/// Strip a trailing slash so `{base}{path}` never doubles up.
pub fn normalize_base(base: &str) -> &str {
base.trim_end_matches('/')
}
/// Join a daemon base URL and one of this module's paths.
pub fn http_url(base: &str, path: &str) -> String {
format!("{}{}", normalize_base(base), path)
}
/// Swap an HTTP base URL for its WebSocket equivalent.
///
/// `http://` becomes `ws://`, `https://` becomes `wss://`, and a base that is
/// already `ws://`/`wss://` passes through. Anything else is an error — a
/// scheme-less base would otherwise produce a URL that fails much later, at
/// connect time, with nothing pointing back at the configuration that caused
/// it.
pub fn ws_base(http_base: &str) -> Result<String, UrlError> {
let base = normalize_base(http_base);
if let Some(rest) = base.strip_prefix("https://") {
Ok(format!("wss://{rest}"))
} else if let Some(rest) = base.strip_prefix("http://") {
Ok(format!("ws://{rest}"))
} else if base.starts_with("wss://") || base.starts_with("ws://") {
Ok(base.to_string())
} else {
Err(UrlError::UnsupportedScheme(http_base.to_string()))
}
}
/// Full `WS /api/events` URL for a daemon reachable at `http_base`.
pub fn events_url(http_base: &str) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, EVENTS))
}
/// Full `WS /api/attach/:id` URL for a daemon reachable at `http_base`.
pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> Uuid {
Uuid::parse_str("6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50").unwrap()
}
#[test]
fn ws_base_swaps_http_and_https() {
assert_eq!(
ws_base("http://127.0.0.1:7070").unwrap(),
"ws://127.0.0.1:7070"
);
assert_eq!(
ws_base("https://laptop.lan:7070").unwrap(),
"wss://laptop.lan:7070"
);
}
#[test]
fn ws_base_passes_through_websocket_schemes() {
assert_eq!(ws_base("ws://10.0.0.1:7070").unwrap(), "ws://10.0.0.1:7070");
assert_eq!(
ws_base("wss://10.0.0.1:7070").unwrap(),
"wss://10.0.0.1:7070"
);
}
#[test]
fn ws_base_rejects_an_unknown_scheme() {
assert_eq!(
ws_base("127.0.0.1:7070"),
Err(UrlError::UnsupportedScheme("127.0.0.1:7070".into()))
);
assert!(ws_base("ftp://host:7070").is_err());
}
#[test]
fn trailing_slashes_never_double_up() {
assert_eq!(ws_base("http://host:7070/").unwrap(), "ws://host:7070");
assert_eq!(
http_url("http://host:7070/", SESSIONS),
"http://host:7070/api/sessions"
);
assert_eq!(
events_url("http://host:7070/").unwrap(),
"ws://host:7070/api/events"
);
}
#[test]
fn builds_per_session_paths() {
assert_eq!(
session(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
);
assert_eq!(
session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
);
assert_eq!(
session_prompt(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/prompt"
);
assert_eq!(
session_focus(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
}
/// The server registers `patterns::*` while clients call `session`/`attach`;
/// nothing but this test keeps the two spellings in step.
#[test]
fn patterns_and_builders_agree() {
assert_eq!(
patterns::SESSION.trim_end_matches(":id"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_PROMPT.trim_end_matches(":id/prompt"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_FOCUS.trim_end_matches(":id/focus"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
}
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [
SESSIONS,
INFO,
DIRS,
RECENT_DIRS,
PROJECTS,
GITROOT,
BRANCHES,
NAMES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
}
}