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>
This commit is contained in:
@@ -39,11 +39,15 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
|
||||
// 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(),
|
||||
@@ -239,6 +243,75 @@ async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user