1529 lines
48 KiB
Rust
1529 lines
48 KiB
Rust
//! Integration tests for the daemon's HTTP + WS API (NAP-243).
|
|
|
|
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use common::DaemonFixture;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use maestro_daemon::local_api;
|
|
use maestro_protocol::{
|
|
ActivityState, ApiError, CreateSessionRequest, Session, SessionStatus, SessionView,
|
|
WorktreeSpec,
|
|
};
|
|
use tempfile::TempDir;
|
|
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,
|
|
_shutdown: oneshot::Sender<()>,
|
|
_fx: DaemonFixture,
|
|
}
|
|
|
|
async fn spawn(hostname: &str) -> Harness {
|
|
spawn_with(
|
|
hostname,
|
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn spawn_with(
|
|
hostname: &str,
|
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
|
) -> Harness {
|
|
spawn_with_leader(hostname, projects, maestro_config::DEFAULT_LEADER).await
|
|
}
|
|
|
|
async fn spawn_with_leader(
|
|
hostname: &str,
|
|
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
|
leader: &str,
|
|
) -> Harness {
|
|
let fx = DaemonFixture::new();
|
|
let manager = fx.manager.clone();
|
|
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
|
// a known set (#26).
|
|
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
|
|
let router = local_api::router(local_api::state_for(
|
|
manager.clone(),
|
|
hostname,
|
|
"/home/test",
|
|
presets,
|
|
projects,
|
|
leader,
|
|
));
|
|
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,
|
|
_shutdown: tx,
|
|
_fx: fx,
|
|
}
|
|
}
|
|
|
|
fn url(addr: SocketAddr, path: &str) -> String {
|
|
format!("http://{addr}{path}")
|
|
}
|
|
|
|
fn ws_url(addr: SocketAddr, path: &str) -> String {
|
|
format!("ws://{addr}{path}")
|
|
}
|
|
|
|
struct FakeAcp {
|
|
dir: TempDir,
|
|
}
|
|
|
|
impl FakeAcp {
|
|
fn new() -> Self {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let bin = dir.path().join("bin");
|
|
std::fs::create_dir_all(&bin).unwrap();
|
|
let adapter = bin.join("claude-code-acp");
|
|
std::fs::copy(
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py"),
|
|
&adapter,
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
Self { dir }
|
|
}
|
|
|
|
fn env(&self) -> HashMap<String, String> {
|
|
HashMap::from([
|
|
(
|
|
"PATH".into(),
|
|
format!(
|
|
"{}:{}",
|
|
self.dir.path().join("bin").display(),
|
|
std::env::var("PATH").unwrap()
|
|
),
|
|
),
|
|
(
|
|
"FAKE_ACP_LOG".into(),
|
|
self.dir.path().join("agent.log").display().to_string(),
|
|
),
|
|
])
|
|
}
|
|
|
|
fn pid(&self) -> u32 {
|
|
std::fs::read_to_string(self.dir.path().join("agent.log"))
|
|
.unwrap_or_default()
|
|
.lines()
|
|
.filter_map(|line| {
|
|
line.strip_prefix("start pid=")?
|
|
.split(' ')
|
|
.next()?
|
|
.parse()
|
|
.ok()
|
|
})
|
|
.next_back()
|
|
.expect("fake ACP started")
|
|
}
|
|
|
|
fn log(&self) -> String {
|
|
std::fs::read_to_string(self.dir.path().join("agent.log")).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
async fn structured_session(h: &Harness, fake: &FakeAcp) -> Uuid {
|
|
structured_session_with_env(h, fake.env()).await
|
|
}
|
|
|
|
async fn structured_session_with_env(h: &Harness, env: HashMap<String, String>) -> Uuid {
|
|
h.manager
|
|
.create(CreateSessionRequest {
|
|
view: SessionView::Structured,
|
|
cwd: "/tmp".into(),
|
|
command: "claude".into(),
|
|
env,
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
async fn wait_for_session_activity(h: &Harness, id: Uuid, expected: ActivityState) {
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
if sessions
|
|
.iter()
|
|
.find(|session| session.id == id)
|
|
.is_some_and(|session| session.activity == expected)
|
|
{
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| panic!("session {id} did not reach {expected:?}"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_ws_sends_snapshot_then_live_projection() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let id = structured_session(&h, &fake).await;
|
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
.await
|
|
.unwrap();
|
|
|
|
let first = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
|
let second = ws.next().await.unwrap().unwrap().into_text().unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&first).unwrap()["type"],
|
|
"transcript_snapshot"
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&second).unwrap()["type"],
|
|
"reduced_state"
|
|
);
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "hello"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
|
let live = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
|
.await
|
|
.expect("projection update arrives on the subscribed stream")
|
|
.unwrap()
|
|
.unwrap()
|
|
.into_text()
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&live).unwrap()["type"],
|
|
"projection_update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_prompt_rejects_a_second_in_flight_turn_and_cancel_clears_it() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let mut env = fake.env();
|
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "200".into());
|
|
let id = structured_session_with_env(&h, env).await;
|
|
let client = reqwest::Client::new();
|
|
let first = client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "first"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
|
let second = client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "second"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.status(), reqwest::StatusCode::CONFLICT);
|
|
let error: ApiError = second.json().await.unwrap();
|
|
assert_eq!(error.error.code, "turn_in_flight");
|
|
|
|
let cancel = client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/cancel"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(cancel.status(), reqwest::StatusCode::NO_CONTENT);
|
|
let next = client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "next"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(next.status(), reqwest::StatusCode::NO_CONTENT);
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if fake.log().matches("recv session/prompt").count() == 2 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("both prompt commands reach ACP");
|
|
let log = fake.log();
|
|
let first_prompt = log.find("recv session/prompt").unwrap();
|
|
let cancel = log.find("recv session/cancel").unwrap();
|
|
let second_prompt = log.rfind("recv session/prompt").unwrap();
|
|
assert!(first_prompt < cancel && cancel < second_prompt, "{log}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_approval_is_secret_single_use_and_race_safe() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let mut env = fake.env();
|
|
env.insert("FAKE_ACP_REQUEST_PERMISSION".into(), "1".into());
|
|
let id = structured_session_with_env(&h, env).await;
|
|
let client = reqwest::Client::new();
|
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
.await
|
|
.unwrap();
|
|
ws.next().await.unwrap().unwrap();
|
|
ws.next().await.unwrap().unwrap();
|
|
assert_eq!(
|
|
client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt")
|
|
))
|
|
.json(&serde_json::json!({"prompt":"needs permission"}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
reqwest::StatusCode::NO_CONTENT
|
|
);
|
|
|
|
let nonce = tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let event: serde_json::Value =
|
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
|
.unwrap();
|
|
if let Some(nonce) = event["state"]["pending_approval"]["nonce"].as_str() {
|
|
assert_eq!(event["transcript"]["row"]["kind"], "approval");
|
|
assert!(event["transcript"]["row"]["data"].get("nonce").is_none());
|
|
break nonce.to_owned();
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
wait_for_session_activity(&h, id, ActivityState::AwaitingInput).await;
|
|
assert!(
|
|
!fake.log().contains(&nonce),
|
|
"daemon nonce leaked to ACP agent"
|
|
);
|
|
|
|
let path = format!("/api/sessions/{id}/structured/approvals/{nonce}");
|
|
let (a, b) = tokio::join!(
|
|
client
|
|
.post(url(h.addr, &path))
|
|
.json(&serde_json::json!("allow_once"))
|
|
.send(),
|
|
client
|
|
.post(url(h.addr, &path))
|
|
.json(&serde_json::json!("deny"))
|
|
.send(),
|
|
);
|
|
assert_eq!(a.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
|
assert_eq!(b.unwrap().status(), reqwest::StatusCode::NO_CONTENT);
|
|
assert_eq!(
|
|
client
|
|
.post(url(h.addr, &path))
|
|
.json(&serde_json::json!("deny"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
reqwest::StatusCode::NO_CONTENT
|
|
);
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if fake.log().contains("permission response") {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let log = fake.log();
|
|
assert!(
|
|
log.contains("\"optionId\": \"once\"") || log.contains("\"optionId\": \"deny\""),
|
|
"{log}"
|
|
);
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
loop {
|
|
let event: serde_json::Value =
|
|
serde_json::from_str(&ws.next().await.unwrap().unwrap().into_text().unwrap())
|
|
.unwrap();
|
|
if event["state"]["pending_approval"].is_null() {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("resolved approval clears the reduced state");
|
|
wait_for_session_activity(&h, id, ActivityState::Idle).await;
|
|
assert_eq!(
|
|
client
|
|
.post(url(
|
|
h.addr,
|
|
&format!(
|
|
"/api/sessions/{}/structured/approvals/{nonce}",
|
|
Uuid::new_v4()
|
|
)
|
|
))
|
|
.json(&serde_json::json!("deny"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
reqwest::StatusCode::NOT_FOUND
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_prompt_failure_clears_the_turn() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let mut env = fake.env();
|
|
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
|
let id = structured_session_with_env(&h, env).await;
|
|
let client = reqwest::Client::new();
|
|
let path = format!("/api/sessions/{id}/structured/prompt");
|
|
let (mut ws, _) = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
.await
|
|
.unwrap();
|
|
ws.next().await.unwrap().unwrap();
|
|
ws.next().await.unwrap().unwrap();
|
|
let first = client
|
|
.post(url(h.addr, &path))
|
|
.json(&serde_json::json!({"prompt":"fails"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), reqwest::StatusCode::NO_CONTENT);
|
|
let mut saw_active_turn = false;
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while let Some(Ok(message)) = ws.next().await {
|
|
let event: serde_json::Value =
|
|
serde_json::from_str(&message.into_text().unwrap()).unwrap();
|
|
let turn = &event["state"]["turn"];
|
|
if turn["responding"] == true {
|
|
saw_active_turn = true;
|
|
}
|
|
if saw_active_turn && turn["responding"] == false && turn["thinking"] == false {
|
|
return;
|
|
}
|
|
}
|
|
panic!("structured websocket closed before the failed turn cleared");
|
|
})
|
|
.await
|
|
.expect("failed turn clears promptly");
|
|
wait_for_session_activity(&h, id, ActivityState::Error).await;
|
|
let second = client
|
|
.post(url(h.addr, &path))
|
|
.json(&serde_json::json!({"prompt":"available"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(second.status(), reqwest::StatusCode::NO_CONTENT);
|
|
assert!(second.bytes().await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_prompt_rejects_unknown_and_unavailable_sessions() {
|
|
let h = spawn("naps-laptop").await;
|
|
let client = reqwest::Client::new();
|
|
let prompt = |id| {
|
|
client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "hello"}))
|
|
};
|
|
|
|
let unknown = prompt(Uuid::new_v4()).send().await.unwrap();
|
|
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
|
let error: ApiError = unknown.json().await.unwrap();
|
|
assert_eq!(error.error.code, "not_found");
|
|
|
|
let terminal = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: SessionView::Terminal,
|
|
cwd: "/tmp".into(),
|
|
command: "sh".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let non_structured = prompt(terminal.id).send().await.unwrap();
|
|
assert_eq!(non_structured.status(), reqwest::StatusCode::CONFLICT);
|
|
let error: ApiError = non_structured.json().await.unwrap();
|
|
assert_eq!(error.error.code, "not_structured");
|
|
|
|
let fake = FakeAcp::new();
|
|
let dead = structured_session(&h, &fake).await;
|
|
h.manager.shutdown().await;
|
|
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
|
let unavailable = prompt(dead).send().await.unwrap();
|
|
assert_eq!(unavailable.status(), reqwest::StatusCode::CONFLICT);
|
|
let error: ApiError = unavailable.json().await.unwrap();
|
|
assert_eq!(error.error.code, "not_structured");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_replay_honors_cursors_and_limit() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let id = structured_session(&h, &fake).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let events: Vec<serde_json::Value> = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/replay?since=0&limit=1"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
if !events.is_empty() {
|
|
return events;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0]["cursor"], 1);
|
|
assert!(events[0].get("notification").is_none());
|
|
|
|
let invalid = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/replay?since=0&before=2"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_replay_pages_projected_events_in_cursor_order() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let id = structured_session(&h, &fake).await;
|
|
let client = reqwest::Client::new();
|
|
unsafe { libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1) };
|
|
let events: Vec<serde_json::Value> = tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let events: Vec<serde_json::Value> = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/replay?before=99&limit=2"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
if events.len() == 2 {
|
|
return events;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
events
|
|
.iter()
|
|
.map(|event| event["cursor"].as_u64().unwrap())
|
|
.collect::<Vec<_>>(),
|
|
vec![1, 2]
|
|
);
|
|
let boundary: Vec<serde_json::Value> = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/replay?before=2&limit=10"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(boundary.len(), 1);
|
|
assert_eq!(boundary[0]["cursor"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_replay_rejects_unknown_terminal_and_dead_sessions() {
|
|
let h = spawn("naps-laptop").await;
|
|
let client = reqwest::Client::new();
|
|
let unknown = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{}/structured/replay", Uuid::new_v4()),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(unknown.status(), reqwest::StatusCode::NOT_FOUND);
|
|
let terminal = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: SessionView::Terminal,
|
|
cwd: "/tmp".into(),
|
|
command: "sh".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let response = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{}/structured/replay", terminal.id),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
|
|
let fake = FakeAcp::new();
|
|
let dead = structured_session(&h, &fake).await;
|
|
h.manager.shutdown().await;
|
|
assert_eq!(h.manager.status(dead).await, Some(SessionStatus::Dead));
|
|
let response = client
|
|
.get(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{dead}/structured/replay"),
|
|
))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
let error: ApiError = response.json().await.unwrap();
|
|
assert_eq!(error.error.code, "not_structured");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_ws_rejects_a_dead_session() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let id = structured_session(&h, &fake).await;
|
|
h.manager.shutdown().await;
|
|
assert_eq!(h.manager.status(id).await, Some(SessionStatus::Dead));
|
|
|
|
let err = connect_async(ws_url(h.addr, &format!("/api/structured/{id}")))
|
|
.await
|
|
.expect_err("dead structured session must not upgrade");
|
|
let message = err.to_string();
|
|
assert!(
|
|
message.contains("409") || message.to_lowercase().contains("conflict"),
|
|
"{message}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn info_returns_daemon_identity() {
|
|
let h = spawn("naps-laptop").await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp["hostname"], "naps-laptop");
|
|
assert_eq!(resp["home_dir"], "/home/test");
|
|
assert!(resp["version"].is_string());
|
|
}
|
|
|
|
/// #159: the web client reads its leader off `/api/info` instead of a
|
|
/// hardcoded constant, so the payload has to carry the configured one — not
|
|
/// the default dressed up as one.
|
|
#[tokio::test]
|
|
async fn info_advertises_the_configured_leader() {
|
|
let h = spawn_with_leader(
|
|
"naps-laptop",
|
|
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
|
"ctrl-b",
|
|
)
|
|
.await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp["leader"], "ctrl-b");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn info_reports_the_default_leader_when_unconfigured() {
|
|
let h = spawn("naps-laptop").await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp["leader"], "ctrl-a");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn info_reports_build_and_instance() {
|
|
// The status indicator needs a release to name and a process to compare
|
|
// against after a reconnect. `commit`/`commit_date` are empty when the
|
|
// binary was built outside a checkout, so only their presence is asserted;
|
|
// `instance` is a fresh uuid per daemon process.
|
|
let h = spawn("naps-laptop").await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(resp["build"]["commit"].is_string());
|
|
assert!(resp["build"]["commit_date"].is_string());
|
|
assert!(resp["build"]["dirty"].is_boolean());
|
|
let instance = resp["instance"].as_str().unwrap();
|
|
assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid");
|
|
|
|
// Same process, same id — a client only treats a *changed* id as a restart.
|
|
let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(again["instance"].as_str().unwrap(), instance);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_sessions_starts_empty_and_reflects_creates() {
|
|
let h = spawn("naps-laptop").await;
|
|
|
|
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(empty.is_empty());
|
|
|
|
let req = CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".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();
|
|
|
|
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 info_advertises_spawn_presets() {
|
|
// #26: the daemon's presets ride the info payload so the spawn form needs
|
|
// no extra round-trip. Built-ins + the implicit custom.
|
|
let h = spawn("naps-laptop").await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/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_returns_empty_without_history() {
|
|
// #26: 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("naps-laptop").await;
|
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp["dirs"].as_array().unwrap().len(), 0);
|
|
assert_eq!(resp["truncated"], false);
|
|
}
|
|
|
|
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
|
|
/// real git checkouts — not mocks.
|
|
#[tokio::test]
|
|
async fn projects_lists_repos_under_the_scan_roots() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
for name in ["alpha", "beta"] {
|
|
let repo = tmp.path().join(name);
|
|
std::fs::create_dir_all(&repo).unwrap();
|
|
for args in [
|
|
vec!["init", "-q"],
|
|
vec![
|
|
"-c",
|
|
"user.email=t@t",
|
|
"-c",
|
|
"user.name=t",
|
|
"commit",
|
|
"-qm",
|
|
"init",
|
|
"--allow-empty",
|
|
],
|
|
] {
|
|
let out = std::process::Command::new("git")
|
|
.arg("-C")
|
|
.arg(&repo)
|
|
.args(&args)
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success());
|
|
}
|
|
}
|
|
|
|
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
|
|
vec![tmp.path().to_path_buf()],
|
|
3,
|
|
tmp.path().to_path_buf(),
|
|
));
|
|
let h = spawn_with("naps-laptop", scanner).await;
|
|
|
|
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
|
names.sort();
|
|
assert_eq!(names, ["alpha", "beta"]);
|
|
for p in &projects {
|
|
assert!(!p.is_worktree);
|
|
assert_eq!(p.base_repo, None);
|
|
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn projects_returns_empty_for_a_disabled_scanner() {
|
|
let h = spawn("naps-laptop").await;
|
|
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(projects.is_empty());
|
|
}
|
|
|
|
/// #53: every returned handle is one word from the shared list.
|
|
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
|
for name in &batch.names {
|
|
assert!(!name.contains('-'), "handle {name:?} is not one word");
|
|
assert!(
|
|
maestro_names::WORDS.contains(&name.as_str()),
|
|
"{name} not in WORDS"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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
|
|
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
|
let h = spawn("naps-laptop").await;
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let resp: serde_json::Value = reqwest::get(url(
|
|
h.addr,
|
|
&format!(
|
|
"/api/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 delete_unknown_session_returns_404() {
|
|
let h = spawn("naps-laptop").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);
|
|
}
|
|
|
|
/// #125: the rename endpoint answers with the updated session, and the name
|
|
/// it stored is the trimmed one.
|
|
#[tokio::test]
|
|
async fn patch_session_renames_it() {
|
|
let h = spawn("naps-laptop").await;
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let resp = reqwest::Client::new()
|
|
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
|
.json(&serde_json::json!({ "name": " crimson-otter " }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
|
let updated: Session = resp.json().await.unwrap();
|
|
assert_eq!(
|
|
updated.metadata.get("name").and_then(|v| v.as_str()),
|
|
Some("crimson-otter")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn patch_unknown_session_returns_404() {
|
|
let h = spawn("naps-laptop").await;
|
|
let resp = reqwest::Client::new()
|
|
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
|
.json(&serde_json::json!({ "name": "nope" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// #145: `POST /.../focus` with `focused: true` over HTTP stamps
|
|
/// `last_focused_at`, observable on the next `GET /api/sessions`.
|
|
#[tokio::test]
|
|
async fn post_focus_stamps_last_focused_at() {
|
|
let h = spawn("naps-laptop").await;
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(session.last_focused_at, None);
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(url(h.addr, &format!("/api/sessions/{}/focus", session.id)))
|
|
.json(&serde_json::json!({ "focused": true }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(sessions[0].last_focused_at.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_owned_session_kills_it() {
|
|
let h = spawn("naps-laptop").await;
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
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);
|
|
}
|
|
|
|
/// Spawn a session through the manager and return its id.
|
|
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
|
h.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
/// The agent activity endpoint on the wire (#105): a report lands in the
|
|
/// session snapshot with its detail.
|
|
#[tokio::test]
|
|
async fn post_activity_reports_error_with_detail() {
|
|
let h = spawn("naps-laptop").await;
|
|
let id = spawn_via_manager(&h).await;
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
|
.json(&serde_json::json!({
|
|
"state": "Error",
|
|
"detail": "cargo build failed"
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
|
assert_eq!(s.activity, maestro_protocol::ActivityState::Error);
|
|
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_activity_to_unknown_session_returns_404() {
|
|
let h = spawn("naps-laptop").await;
|
|
let resp = reqwest::Client::new()
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
|
|
))
|
|
.json(&serde_json::json!({ "state": "Idle" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// Unknown state values are rejected rather than coerced (#105).
|
|
#[tokio::test]
|
|
async fn post_activity_rejects_an_unknown_state_value() {
|
|
let h = spawn("naps-laptop").await;
|
|
let id = spawn_via_manager(&h).await;
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
|
.json(&serde_json::json!({ "state": "Teleported" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert!(resp.status().is_client_error(), "got {}", resp.status());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn post_activity_caps_an_overlong_detail_server_side() {
|
|
let h = spawn("naps-laptop").await;
|
|
let id = spawn_via_manager(&h).await;
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
|
.json(&serde_json::json!({
|
|
"state": "Error",
|
|
"detail": "x".repeat(10_000)
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
|
assert_eq!(
|
|
s.activity_detail.as_ref().map(|d| d.chars().count()),
|
|
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
|
|
);
|
|
}
|
|
|
|
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
|
/// with the structured breakdown, and the session keeps running.
|
|
#[tokio::test]
|
|
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
|
|
let h = spawn("naps-laptop").await;
|
|
let repo = tempfile::TempDir::new().unwrap();
|
|
common::init_repo(repo.path());
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 60".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/guarded".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
std::fs::write(
|
|
repo.path()
|
|
.join("worktrees")
|
|
.join("feat")
|
|
.join("guarded")
|
|
.join("README"),
|
|
b"unsaved edits\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let resp = reqwest::Client::new()
|
|
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
|
let body: ApiError = resp.json().await.unwrap();
|
|
assert_eq!(body.error.code, "kill_guarded");
|
|
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
|
|
assert_eq!(breakdown.uncommitted_files, 1);
|
|
|
|
assert!(h.manager.has(session.id).await, "the session keeps running");
|
|
}
|
|
|
|
/// `?force=true` on the wire (#81): the refusal is overridden and the session
|
|
/// ends, while the guarded worktree survives on disk.
|
|
#[tokio::test]
|
|
async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
|
|
let h = spawn("naps-laptop").await;
|
|
let repo = tempfile::TempDir::new().unwrap();
|
|
common::init_repo(repo.path());
|
|
let wt_path = repo
|
|
.path()
|
|
.join("worktrees")
|
|
.join("feat")
|
|
.join("guarded")
|
|
.to_path_buf();
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 60".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/guarded".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
|
|
let resp = reqwest::Client::new()
|
|
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
|
.query(&[("force", "true")])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
|
|
|
// The tree would have blocked; force must not have removed it.
|
|
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
|
assert!(!h.manager.has(session.id).await, "the session is gone");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attach_ws_streams_pty_bytes() {
|
|
let h = spawn("naps-laptop").await;
|
|
let session = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
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-attach\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-attach") {
|
|
return true;
|
|
}
|
|
}
|
|
Some(Ok(_)) => continue,
|
|
Some(Err(_)) | None => return false,
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or(false);
|
|
assert!(saw_hello, "PTY did not echo through the attach WS");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pty_routes_reject_structured_sessions_as_not_terminal() {
|
|
let h = spawn("naps-laptop").await;
|
|
let fake = FakeAcp::new();
|
|
let id = structured_session(&h, &fake).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(url(h.addr, &format!("/api/sessions/{id}/input")))
|
|
.json(&maestro_protocol::SendInputRequest {
|
|
text: "ignored".into(),
|
|
submit: false,
|
|
})
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
let error: ApiError = response.json().await.unwrap();
|
|
assert_eq!(error.error.code, "not_terminal");
|
|
assert_eq!(error.error.message, "session is not a terminal session");
|
|
|
|
let error = connect_async(ws_url(h.addr, &format!("/api/attach/{id}")))
|
|
.await
|
|
.expect_err("structured session must not upgrade to a PTY socket");
|
|
let tokio_tungstenite::tungstenite::Error::Http(response) = error else {
|
|
panic!("expected an HTTP handshake rejection, got {error}");
|
|
};
|
|
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
|
|
let body = response.body().as_deref().expect("ApiError response body");
|
|
let error: ApiError = serde_json::from_slice(body).unwrap();
|
|
assert_eq!(error.error.code, "not_terminal");
|
|
assert_eq!(error.error.message, "session is not a terminal session");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attach_ws_for_unknown_session_returns_404() {
|
|
let h = spawn("naps-laptop").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}"
|
|
);
|
|
}
|
|
|
|
/// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract
|
|
/// and the subscribe-before-snapshot ordering were only described in a comment.
|
|
#[tokio::test]
|
|
async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
|
let h = spawn("konishi").await;
|
|
let cwd = tempfile::TempDir::new().unwrap();
|
|
|
|
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
|
.await
|
|
.expect("events ws");
|
|
|
|
match next_event(&mut ws).await {
|
|
maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()),
|
|
other => panic!("first frame must be a Snapshot, got {other:?}"),
|
|
}
|
|
|
|
let created = h
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
match next_event(&mut ws).await {
|
|
maestro_protocol::CatalogEvent::SessionAdded { session } => {
|
|
assert_eq!(session.id, created.id);
|
|
}
|
|
other => panic!("expected SessionAdded, got {other:?}"),
|
|
}
|
|
|
|
h.manager.kill(created.id, false).await.expect("kill");
|
|
|
|
// Activity ticks may interleave; the removal is what we are waiting for.
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"timed out waiting for SessionRemoved"
|
|
);
|
|
if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await {
|
|
assert_eq!(id, created.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn events_ws_streams_structured_activity_without_hook_overrides() {
|
|
let h = spawn("konishi").await;
|
|
let fake = FakeAcp::new();
|
|
let mut env = fake.env();
|
|
env.insert("FAKE_ACP_DELAY_PROMPT_MS".into(), "500".into());
|
|
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
|
.await
|
|
.expect("events ws");
|
|
assert!(matches!(
|
|
next_event(&mut ws).await,
|
|
maestro_protocol::CatalogEvent::Snapshot { .. }
|
|
));
|
|
|
|
let id = structured_session_with_env(&h, env).await;
|
|
let maestro_protocol::CatalogEvent::SessionAdded { session } = next_event(&mut ws).await else {
|
|
panic!("expected structured SessionAdded")
|
|
};
|
|
assert_eq!(session.activity, ActivityState::Idle);
|
|
|
|
let client = reqwest::Client::new();
|
|
let prompt = client
|
|
.post(url(
|
|
h.addr,
|
|
&format!("/api/sessions/{id}/structured/prompt"),
|
|
))
|
|
.json(&serde_json::json!({"prompt": "hello"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(prompt.status(), reqwest::StatusCode::NO_CONTENT);
|
|
let report = client
|
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
|
.json(&serde_json::json!({"state": "Error"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(report.status(), reqwest::StatusCode::NO_CONTENT);
|
|
wait_for_session_activity(&h, id, ActivityState::Active).await;
|
|
|
|
let mut activities = Vec::new();
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if let maestro_protocol::CatalogEvent::SessionUpdated { session } =
|
|
next_event(&mut ws).await
|
|
{
|
|
activities.push(session.activity);
|
|
if session.activity == ActivityState::Idle {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("structured turn returns to idle");
|
|
assert!(
|
|
activities.contains(&ActivityState::Active),
|
|
"{activities:?}"
|
|
);
|
|
assert!(
|
|
!activities.contains(&ActivityState::Error),
|
|
"{activities:?}"
|
|
);
|
|
|
|
h.manager.kill(id, false).await.unwrap();
|
|
}
|
|
|
|
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
|
/// two clients connected at different times both get their own Snapshot.
|
|
#[tokio::test]
|
|
async fn every_events_subscriber_gets_its_own_snapshot() {
|
|
let h = spawn("konishi").await;
|
|
let cwd = tempfile::TempDir::new().unwrap();
|
|
|
|
let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
|
|
let _ = next_event(&mut first).await;
|
|
|
|
h.manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
|
|
match next_event(&mut second).await {
|
|
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
|
|
assert_eq!(
|
|
sessions.len(),
|
|
1,
|
|
"a late subscriber must see current state"
|
|
);
|
|
}
|
|
other => panic!("expected Snapshot, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
|
loop {
|
|
match tokio::time::timeout(Duration::from_secs(5), ws.next())
|
|
.await
|
|
.expect("events ws timed out")
|
|
{
|
|
Some(Ok(WsMessage::Text(t))) => {
|
|
return serde_json::from_str(&t).expect("decode catalog event")
|
|
}
|
|
Some(Ok(_)) => continue,
|
|
other => panic!("unexpected events frame: {other:?}"),
|
|
}
|
|
}
|
|
}
|