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:
2026-06-20 21:09:18 +00:00
co-authored by Claude Opus 4.8
parent ac11997f58
commit cc8412ca59
11 changed files with 776 additions and 5 deletions
+73
View File
@@ -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;