# Conflicts: # crates/maestro-daemon/Cargo.toml # crates/maestro-daemon/src/local_api.rs # crates/maestro/Cargo.toml
263 lines
8.8 KiB
Rust
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));
|
|
}
|
|
}
|