From a970ba89c2033199c379c7a85e24f0f307e4588f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:13:46 +0100 Subject: [PATCH] feat(maestro): GET /api/names serves name batches (#53) --- Cargo.lock | 1 + Cargo.toml | 1 + crates/maestro-daemon/Cargo.toml | 2 + crates/maestro-daemon/src/local_api.rs | 19 ++++++++- crates/maestro-daemon/tests/local_api.rs | 51 ++++++++++++++++++++++++ crates/maestro-protocol/src/lib.rs | 7 ++++ crates/maestro-protocol/src/routes.rs | 17 +++++++- 7 files changed, 96 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f95fc32..6fe210f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "futures-util", "libc", "maestro-config", + "maestro-names", "maestro-protocol", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index a5cc5ef..7fb7deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } libc = "0.2" maestro-config = { path = "crates/maestro-config" } +maestro-names = { path = "crates/maestro-names" } maestro-protocol = { path = "crates/maestro-protocol" } maestro-testing = { path = "crates/maestro-testing" } maestro-tui = { path = "crates/maestro-tui" } diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 112f736..24c5c52 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -18,6 +18,7 @@ dirs.workspace = true futures-util.workspace = true libc.workspace = true maestro-config.workspace = true +maestro-names.workspace = true maestro-protocol.workspace = true serde.workspace = true serde_json.workspace = true @@ -32,5 +33,6 @@ reqwest.workspace = true tokio-tungstenite.workspace = true urlencoding = "2" tempfile.workspace = true +maestro-names.workspace = true # `test-util` enables `start_paused` for instant timeout tests (NAP-270). tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 515324b..2f4bf12 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -19,7 +19,7 @@ use axum::routing::{any, delete, get}; use axum::{Json, Router}; use maestro_config::SpawnPreset; use maestro_protocol::{ - routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session, + routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session, PROTOCOL_VERSION, }; use serde::Deserialize; @@ -48,6 +48,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::PROJECTS, get(projects)) .route(routes::GITROOT, get(git_root)) + .route(routes::NAMES, get(names)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) .route(routes::patterns::ATTACH, any(attach_ws)) @@ -121,6 +122,22 @@ async fn git_root(State(s): State, Query(q): Query) -> .into_response() } +/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at +/// [`maestro_names::MAX_BATCH`] (#53). +#[derive(Debug, Deserialize)] +struct NamesQuery { + count: Option, +} + +/// Fresh session handles for the spawn form's instant reroll (#53). The web +/// client fetches one batch per draft pane and rerolls locally from it. +async fn names(Query(q): Query) -> Json { + let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH); + Json(NameBatch { + names: maestro_names::generate_batch(count), + }) +} + async fn daemon_info(State(s): State) -> Json { Json(DaemonInfo { hostname: (*s.hostname).clone(), diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 194df70..b04abeb 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -232,6 +232,57 @@ async fn projects_returns_empty_for_a_disabled_scanner() { assert!(projects.is_empty()); } +/// #53: every returned handle splits into a known adjective and noun. +fn assert_valid_names(batch: &maestro_protocol::NameBatch) { + for name in &batch.names { + let (adj, noun) = name.split_once('-').unwrap_or_else(|| { + panic!("handle {name:?} is not adjective-noun"); + }); + assert!( + maestro_names::ADJECTIVES.contains(&adj), + "{adj} not in ADJECTIVES" + ); + assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS"); + } +} + +#[tokio::test] +async fn names_defaults_to_one_handle() { + let h = spawn("naps-laptop").await; + let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(batch.names.len(), 1); + assert_valid_names(&batch); +} + +#[tokio::test] +async fn names_honors_count_and_caps_it() { + use maestro_names::MAX_BATCH; + let h = spawn("naps-laptop").await; + + let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(batch.names.len(), 5); + assert_valid_names(&batch); + + let batch: maestro_protocol::NameBatch = + reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10))) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(batch.names.len(), MAX_BATCH); +} + #[tokio::test] async fn gitroot_probes_path() { // #26: resolves the query against the daemon home dir and walks up for a diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index b06e3b7..b517f48 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -279,6 +279,13 @@ pub struct DaemonInfo { pub presets: Vec, } +/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh +/// `adjective-noun` session handles generated by the `maestro-names` crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NameBatch { + pub names: Vec, +} + // --------------------------------------------------------------------------- // Catalog events (`WS /api/events`) // --------------------------------------------------------------------------- diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 777d086..0c1bffc 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -21,12 +21,18 @@ pub const RECENT_DIRS: &str = "/api/recent-dirs"; 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` 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`] and [`GITROOT`] both take. pub const PATH_QUERY: &str = "path"; +/// Name of the query parameter [`NAMES`] takes. +pub const COUNT_QUERY: &str = "count"; + /// `DELETE /api/sessions/:id`. pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}") @@ -174,7 +180,16 @@ mod tests { #[test] fn every_route_sits_under_the_api_prefix() { - for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] { + for path in [ + SESSIONS, + INFO, + DIRS, + RECENT_DIRS, + PROJECTS, + GITROOT, + NAMES, + EVENTS, + ] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX));