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
naps62-yoloandClaude Opus 4.8 458c81094b feat(maestro): nest sessions by live working directory, drop labels
Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.

Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.

The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.

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

729 lines
23 KiB
Rust

//! Integration tests for the client-facing HTTP API (NAP-215).
//!
//! Spins up a registry on `:0`, plus a tiny mock-daemon WS client that exists
//! purely to satisfy `CreateSession` / `KillSession` round-trips. All
//! HTTP/WS calls go over the wire via `reqwest` and `tokio-tungstenite`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use maestro_protocol::{
ConnectInfo, CreateSessionRequest, DaemonInbound, DaemonInfo, DaemonOutbound, JwtClaims,
RegistryEvent, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_full, Registry, COOKIE_NAME};
use reqwest::Client;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::{oneshot, Mutex};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{
connect_async, tungstenite::client::IntoClientRequest, MaybeTlsStream, WebSocketStream,
};
use uuid::Uuid;
const TOKEN: &str = "http-api-test-token";
const TEST_HEARTBEAT: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
impl Harness {
fn base(&self) -> String {
format!("http://{}", self.addr)
}
fn ws_base(&self) -> String {
format!("ws://{}", self.addr)
}
}
async fn spawn(rpc_timeout: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_full(
registry.clone(),
TOKEN.to_string(),
TEST_HEARTBEAT,
rpc_timeout,
);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
// ---------------------------------------------------------------------------
// Mock daemon
// ---------------------------------------------------------------------------
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
#[derive(Clone, Copy)]
enum DaemonBehavior {
/// Replies immediately with the session the request asked for.
ReplyOk,
/// Never replies — exercises the registry's 504 timeout path.
Hang,
}
#[derive(Clone)]
struct MockDaemon {
daemon_id: String,
daemon_url: String,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
}
impl MockDaemon {
fn new(daemon_id: &str, daemon_url: &str) -> Self {
Self {
daemon_id: daemon_id.into(),
daemon_url: daemon_url.into(),
captured: Arc::new(Mutex::new(Vec::new())),
}
}
async fn spawn(self, addr: SocketAddr, behavior: DaemonBehavior) {
let url = format!("ws://{addr}/daemon");
let (mut ws, _) = connect_async(url).await.expect("ws connect");
let hello = DaemonOutbound::Hello {
daemon_id: self.daemon_id.clone(),
hostname: "mock-host".into(),
version: "0.1.0".into(),
token: TOKEN.into(),
daemon_url: self.daemon_url.clone(),
home_dir: String::new(),
};
ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap()))
.await
.unwrap();
// consume the Ack
let _ = ws.next().await;
// send an empty initial snapshot so the registry's view is consistent
ws.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionsSnapshot { sessions: vec![] }).unwrap(),
))
.await
.unwrap();
let captured = self.captured.clone();
let daemon_id = self.daemon_id.clone();
tokio::spawn(async move {
run_loop(ws, behavior, captured, daemon_id).await;
});
}
async fn captured(&self) -> Vec<DaemonInbound> {
self.captured.lock().await.clone()
}
}
async fn run_loop(
mut ws: WsClient,
behavior: DaemonBehavior,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
daemon_id: String,
) {
while let Some(Ok(WsMessage::Text(text))) = ws.next().await {
let msg: DaemonInbound = match serde_json::from_str(&text) {
Ok(m) => m,
Err(_) => continue,
};
captured.lock().await.push(msg.clone());
match (behavior, msg) {
(DaemonBehavior::ReplyOk, DaemonInbound::CreateSession { reply_to, request }) => {
let session = mock_session(&daemon_id, &request);
// Send SessionAdded BEFORE the RPC result so the registry's
// catalog is updated by the time the HTTP caller resumes.
// The registry processes inbound frames serially in order,
// so this ordering guarantee holds end-to-end.
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionAdded {
session: session.clone(),
})
.unwrap(),
))
.await;
let reply = DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok { value: session },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::ReplyOk, DaemonInbound::KillSession { reply_to, id }) => {
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionRemoved { id }).unwrap(),
))
.await;
let reply = DaemonOutbound::KillSessionResult {
reply_to,
result: RpcResult::Ok { value: () },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::Hang, _) => {
// intentionally do nothing
}
_ => {}
}
}
}
fn mock_session(daemon_id: &str, request: &CreateSessionRequest) -> Session {
// If a worktree spec was attached, pretend the daemon "resolved" the cwd
// to <base_repo>/.worktrees/<branch> so the test can assert on it.
let cwd = request
.worktree
.as_ref()
.map(|w| w.base_repo.join(".worktrees").join(&w.branch))
.unwrap_or_else(|| request.cwd.clone());
Session {
id: Uuid::new_v4(),
daemon_id: daemon_id.into(),
cwd,
command: request.command.clone(),
env: request.env.clone(),
metadata: request.metadata.clone(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
fn client_with_cookies() -> Client {
reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn first_request_sets_cookie_and_subsequent_reuses_it() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let r1 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r1.status().is_success());
let cookies: Vec<_> = r1
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"first request must include a Set-Cookie"
);
let raw = cookies[0].to_str().unwrap();
assert!(raw.contains(COOKIE_NAME));
assert!(raw.contains("HttpOnly"));
assert!(raw.contains("SameSite=Strict"));
let r2 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r2.status().is_success());
let new_cookies: Vec<_> = r2
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
new_cookies.is_empty(),
"valid existing cookie should not be reissued, got {new_cookies:?}"
);
}
#[tokio::test]
async fn tampered_cookie_is_replaced_silently() {
let h = spawn(Duration::from_secs(5)).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.header(
reqwest::header::COOKIE,
format!("{}=not-a-jwt", COOKIE_NAME),
)
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let cookies: Vec<_> = resp
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"tampered cookie must be replaced with a freshly-signed one"
);
}
#[tokio::test]
async fn list_daemons_includes_url() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let body: Vec<DaemonInfo> = resp.json().await.unwrap();
assert_eq!(body.len(), 1);
assert_eq!(body[0].daemon_id, "laptop");
assert_eq!(body[0].daemon_url, "ws://10.0.0.1:8081");
}
#[tokio::test]
async fn post_sessions_round_trips_to_daemon() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(session.daemon_id, "laptop");
assert_eq!(session.command, "bash");
// GET /api/sessions should now see it.
let resp = client
.get(format!("{}/api/sessions", h.base()))
.send()
.await
.unwrap();
let listed: Vec<Session> = resp.json().await.unwrap();
assert!(listed.iter().any(|s| s.id == session.id));
}
#[tokio::test]
async fn post_sessions_returns_504_on_daemon_timeout() {
// Short RPC timeout so the 504 path is fast.
let h = spawn(Duration::from_millis(200)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::Hang).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 504);
// The daemon should have received the CreateSession even though it never
// replied.
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::CreateSession { .. })),
"daemon should have seen CreateSession, got {captured:?}"
);
}
#[tokio::test]
async fn post_sessions_with_worktree_forwards_spec_and_returns_resolved_cwd() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let body = serde_json::json!({
"daemon_id": "laptop",
"cwd": "/home/naps62/projects/maestro",
"command": "claude",
"worktree": {
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/test",
"cleanup_on_kill": true
}
});
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(
session.cwd,
PathBuf::from("/home/naps62/projects/maestro/.worktrees/feat/test")
);
let captured = mock.captured().await;
let forwarded = captured
.iter()
.find_map(|m| match m {
DaemonInbound::CreateSession { request, .. } => Some(request.clone()),
_ => None,
})
.expect("CreateSession was not forwarded");
let spec = forwarded.worktree.expect("worktree spec dropped");
assert_eq!(spec.branch, "feat/test");
assert!(spec.cleanup_on_kill);
}
#[tokio::test]
async fn delete_session_round_trips() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
// The mock daemon doesn't actually update its catalog after kill — but the
// registry's catalog reflects whatever the daemon broadcasts. We're
// asserting only on the HTTP response.
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), session.id))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 204);
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::KillSession { id, .. } if *id == session.id)),
"daemon should have seen KillSession, got {captured:?}"
);
}
#[tokio::test]
async fn delete_session_returns_404_for_unknown_session() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), Uuid::new_v4()))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
}
#[tokio::test]
async fn connect_info_issues_valid_jwt() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let resp = client
.get(format!(
"{}/api/sessions/{}/connect-info",
h.base(),
session.id
))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let info: ConnectInfo = resp.json().await.unwrap();
assert_eq!(info.daemon_url, "ws://10.0.0.1:8081");
// Verify the JWT against the same shared secret a daemon would use.
let decoded = decode::<JwtClaims>(
&info.jwt,
&DecodingKey::from_secret(TOKEN.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.expect("jwt decodes");
assert_eq!(decoded.claims.daemon_id, "laptop");
assert_eq!(decoded.claims.session_id, session.id);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
#[tokio::test]
async fn events_ws_pushes_snapshot_then_deltas() {
let h = spawn(Duration::from_secs(5)).await;
// First obtain a valid cookie via a plain HTTP call.
let client = client_with_cookies();
let _ = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
let cookie_value = harvest_cookie(&client, &h.base()).await;
// Now open the events WS with that cookie.
let url = format!("{}/api/events", h.ws_base());
let mut req = url.into_client_request().unwrap();
req.headers_mut().insert(
reqwest::header::COOKIE,
format!("{}={}", COOKIE_NAME, cookie_value).parse().unwrap(),
);
let (mut ws, _) = connect_async(req).await.expect("connect events ws");
let snapshot = recv_event(&mut ws).await;
match snapshot {
RegistryEvent::Snapshot { daemons, sessions } => {
assert!(daemons.is_empty());
assert!(sessions.is_empty());
}
other => panic!("expected Snapshot first, got {other:?}"),
}
// Now connect a daemon — subscriber should see DaemonAdded.
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let event = recv_event(&mut ws).await;
match event {
RegistryEvent::DaemonAdded { daemon } => assert_eq!(daemon.daemon_id, "laptop"),
other => panic!("expected DaemonAdded, got {other:?}"),
}
// Spawn a session via HTTP; subscriber should see SessionAdded.
let _: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let event = recv_event(&mut ws).await;
assert!(
matches!(event, RegistryEvent::SessionAdded { .. }),
"expected SessionAdded, got {event:?}"
);
}
#[tokio::test]
async fn root_serves_no_ui() {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). `/` has no route.
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client.get(h.base()).send().await.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async fn harvest_cookie(client: &Client, base: &str) -> String {
// Reqwest's cookie store keeps the cookie, but doesn't expose values
// directly. Easiest: re-issue a request without the store and read the
// Set-Cookie that comes back.
let bare = reqwest::Client::new();
let resp = bare
.get(format!("{base}/api/daemons"))
.send()
.await
.unwrap();
let raw = resp
.headers()
.get(reqwest::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
// raw looks like "maestro_session=<value>; HttpOnly; ..."
let prefix = format!("{COOKIE_NAME}=");
let body = raw.strip_prefix(&prefix).expect("cookie prefix");
let value = body.split(';').next().unwrap();
let _ = client; // unused — kept in signature for symmetry
value.to_string()
}
async fn recv_event(ws: &mut WsClient) -> RegistryEvent {
let frame = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.expect("event arrived")
.expect("ws open")
.expect("ws frame");
match frame {
WsMessage::Text(t) => match serde_json::from_str::<RegistryEvent>(&t) {
Ok(e) => e,
Err(e) => {
let parsed: Value = serde_json::from_str(&t).unwrap();
panic!("could not parse event {parsed:?}: {e}");
}
},
other => panic!("expected text frame, got {other:?}"),
}
}