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-daemon/tests/local_api.rs
T
naps62-yoloandClaude Opus 4.8 cc8412ca59 feat(maestro): spawn protocol types + relay + daemon HTTP for recents/gitroot/presets (#26)
Expose the daemon-side spawn data (#24 recents, #25 gitroot, #22 presets) to
the web, mirroring the NAP-329 ListDir broker plumbing end to end so it works
across federated hosts.

Wire (maestro-protocol): `RecentDir`/`RecentDirs`/`GitRoot` payloads;
`DaemonInbound::RecentDirs`/`GitRoot` requests + `ForwardRecentDirs`/
`ForwardGitRoot` relay pairs + their `*Result` replies on both enums. All
forward-compatible via the existing `RpcResult` + `#[serde(other)] Unknown`.

Relay (broker + registry + registry_client): home daemon → registry → owning
daemon → back, with the same fail-fast-while-disconnected + RAII pending-entry
cleanup discipline as ListDir. The owning daemon answers RecentDirs from its
spawn history and GitRoot by resolving the path against its home dir
(shared `dirlist::resolve`) then walking up via `gitroot::find_root`.

Daemon HTTP (maestro-daemon):
- GET /api/daemons/:id/recent-dirs → RecentDirs
- GET /api/daemons/:id/gitroot?path= → GitRoot
- Presets ride GET /api/web/info (the documented DaemonInfo sibling) so the
  form needs no extra round-trip; resolved once at startup from [spawn].presets
  (a bad preset list now refuses startup).

Tests: protocol round-trip + forward-compat for the new variants; broker
forward round-trips + fail-fast; HTTP handler tests (self recents/gitroot,
presets advertisement, peer 503 without a registry link).

Closes #26

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:09:18 +00:00

405 lines
13 KiB
Rust

//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243).
//!
//! The legacy `/sessions/:id/stream` endpoint is covered by `tests/attach.rs`;
//! this file exercises the new `/api/*` surface and the same-host broker WS.
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus};
use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
addr: SocketAddr,
manager: maestro_daemon::SessionManager,
catalog: CatalogMirror,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
let manager = fx.manager.clone();
let catalog = CatalogMirror::new();
let events = LocalEventBus::new();
// NAP-247/NAP-327: tests don't exercise the cross-host broker path; hand the
// routers an isolated BrokerHandle whose receiver is dropped, so any
// resolve_attach / forward_create / forward_kill fails fast with
// NoControlLink (the handle starts disconnected).
let (broker, _broker_rx) = BrokerHandle::new();
// Built-in presets (claude/opencode/zsh + custom) so web_info advertises a
// known set (#26).
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let state = local_api::state_for(
manager.clone(),
hostname,
daemon_url,
"/home/test",
presets,
catalog.clone(),
events,
broker.clone(),
);
let broker_state = local_api::BrokerState {
manager: manager.clone(),
catalog: catalog.clone(),
broker,
self_daemon_id: std::sync::Arc::new(daemon_id.into()),
self_perimeter_id: None,
};
let router = local_api::router(state).merge(local_api::broker_router(broker_state));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
manager,
catalog,
_shutdown: tx,
_fx: fx,
}
}
fn url(addr: SocketAddr, path: &str) -> String {
format!("http://{addr}{path}")
}
#[tokio::test]
async fn web_info_returns_daemon_identity() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["daemon_id"], "laptop");
assert_eq!(resp["hostname"], "naps-laptop");
assert_eq!(resp["daemon_url"], "ws://10.0.0.1:7070");
assert!(resp["version"].is_string());
}
#[tokio::test]
async fn list_daemons_includes_self_and_mirrored_peers() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Before any catalog events arrive, the daemon only knows about itself.
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].daemon_id, "laptop");
// Simulate the registry fanning out a peer addition.
h.catalog
.apply(RegistryEvent::DaemonAdded {
daemon: DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi-host".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
})
.await;
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 2);
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
assert!(ids.contains(&"laptop"));
assert!(ids.contains(&"konishi"));
}
#[tokio::test]
async fn list_sessions_unions_own_and_peer() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Create one local session.
let own = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
// Inject a peer session via the mirror.
let mut peer = own.clone();
peer.id = Uuid::new_v4();
peer.daemon_id = "konishi".into();
h.catalog
.apply(RegistryEvent::SessionAdded {
session: peer.clone(),
})
.await;
let list: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<Uuid> = list.iter().map(|s| s.id).collect();
assert!(ids.contains(&own.id), "own session missing: {ids:?}");
assert!(ids.contains(&peer.id), "peer session missing: {ids:?}");
}
#[tokio::test]
async fn list_sessions_starts_empty_and_reflects_creates() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(empty.is_empty());
let req = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: h.manager.daemon_id().into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let created: Session = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
.json(&req)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(created.daemon_id, "laptop");
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(after.len(), 1);
assert_eq!(after[0].id, created.id);
}
#[tokio::test]
async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() {
// Cross-daemon create now brokers to the owner over the registry control
// link (NAP-327). This harness has no registry link (the broker starts
// disconnected), so the daemon fails fast with a retryable 503 instead of
// the old 501 — proving it attempts to broker rather than refusing.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
.json(&req)
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn web_info_advertises_spawn_presets() {
// #26: the connected daemon's presets ride the web_info payload so the
// spawn form needs no extra round-trip. Built-ins + the implicit custom.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<&str> = resp["presets"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap())
.collect();
assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]);
}
#[tokio::test]
async fn recent_dirs_self_returns_empty_without_history() {
// #26: the self-path reads this daemon's spawn history. The test fixture
// wires no history, so the list is empty but the endpoint is well-formed.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/daemons/laptop/recent-dirs"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["dirs"].as_array().unwrap().len(), 0);
assert_eq!(resp["truncated"], false);
}
#[tokio::test]
async fn gitroot_self_probes_path() {
// #26: the self-path resolves the query against the daemon home dir and
// walks up for a `.git`. A `/tmp`-rooted temp dir is not in a repo → null.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
h.addr,
&format!(
"/api/daemons/laptop/gitroot?path={}",
urlencoding::encode(&tmp.path().to_string_lossy())
),
))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp.get("root").is_none() || resp["root"].is_null());
}
#[tokio::test]
async fn recent_dirs_for_peer_503s_without_registry_link() {
// #26: a peer target brokers over the (absent) registry link, failing fast
// with a retryable 503 — same shape as the cross-daemon create path.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp = reqwest::get(url(h.addr, "/api/daemons/konishi/recent-dirs"))
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn delete_unknown_session_returns_404() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!h.manager.has(session.id).await);
}
#[tokio::test]
async fn broker_ws_attaches_to_local_session() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let ws_url = format!("ws://{}/api/attach/{}", h.addr, session.id);
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
// Provoke output. The bash process should echo this back via the PTY.
ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec()))
.await
.unwrap();
let saw_hello = tokio::time::timeout(Duration::from_secs(2), async {
loop {
match ws.next().await {
Some(Ok(WsMessage::Binary(bytes))) => {
if String::from_utf8_lossy(&bytes).contains("hello-broker") {
return true;
}
}
Some(Ok(_)) => continue,
Some(Err(_)) | None => return false,
}
}
})
.await
.unwrap_or(false);
assert!(saw_hello, "PTY did not echo through the broker WS");
}
#[tokio::test]
async fn broker_ws_for_unknown_session_returns_404_not_502() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
let err = connect_async(ws_url)
.await
.expect_err("expected handshake fail");
let msg = err.to_string();
assert!(
msg.contains("404") || msg.to_lowercase().contains("not found"),
"{msg}"
);
}