From 1f20d70a2e52c3da87f59a72fced9c65b3768e48 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Mon, 15 Jun 2026 21:11:43 +0000 Subject: [PATCH] feat(maestro): add ListDir/ForwardListDir wire protocol + DirListing (NAP-329) --- crates/maestro-protocol/src/lib.rs | 117 +++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 9dcfd4d..22a0603 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -66,6 +66,25 @@ pub struct CreateSessionRequest { pub worktree: Option, } +/// Result of a `ListDir` query: the immediate subdirectories of one directory +/// on a daemon's host. Used to drive the web spawn form's working-directory +/// autocomplete (NAP-329). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirListing { + /// The resolved absolute directory that was listed, after the daemon + /// expanded `~`/relative/empty inputs against its own home dir. The client + /// uses it to know what an abbreviated query resolved to. + pub path: PathBuf, + /// Immediate subdirectory names (not full paths), sorted case-insensitively. + /// Directories only — the spawn form's targets are always dirs. Hidden + /// entries (dotfiles) are included, matching shell completion. + pub entries: Vec, + /// True when `entries` was truncated at the daemon's cap; the client can + /// surface "narrow your search" rather than implying the dir is small. + #[serde(default)] + pub truncated: bool, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { @@ -158,6 +177,21 @@ pub enum DaemonOutbound { daemon_id: String, id: Uuid, }, + /// Owning daemon's reply to a [`DaemonInbound::ListDir`] — the requested + /// directory listing on its host. (NAP-329) + ListDirResult { + reply_to: ReplyTo, + result: RpcResult, + }, + /// Home daemon asks the registry to relay a directory listing to the daemon + /// named by `daemon_id` (which may be a peer). Control-plane only, like + /// [`ForwardCreate`](Self::ForwardCreate). Reply: + /// [`DaemonInbound::ForwardListDirResult`]. (NAP-329) + ForwardListDir { + reply_to: ReplyTo, + daemon_id: String, + path: String, + }, /// Target daemon answers a [`DaemonInbound::RelaySetup`]: whether it will /// accept relay traffic for the requested session. Registry only allocates /// relay capacity when `accepted == true`. @@ -204,6 +238,19 @@ pub enum DaemonInbound { reply_to: ReplyTo, result: RpcResult<()>, }, + /// Registry asks a daemon to list `path` on its host. The daemon expands + /// `~`/relative/empty against its own home dir, then replies with + /// [`DaemonOutbound::ListDirResult`]. (NAP-329) + ListDir { + reply_to: ReplyTo, + path: String, + }, + /// Registry's reply to [`DaemonOutbound::ForwardListDir`] — the owning + /// daemon's listing, relayed back to the home daemon. (NAP-329) + ForwardListDirResult { + reply_to: ReplyTo, + result: RpcResult, + }, /// Registry signals a target daemon that a home daemon wants to attach via /// the registry-mediated relay (T3). Target evaluates `allow_inbound` and /// replies with [`DaemonOutbound::RelayAck`]. @@ -847,6 +894,76 @@ mod tests { } } + #[test] + fn dir_listing_round_trips_and_truncated_defaults() { + let listing = DirListing { + path: PathBuf::from("/home/naps62"), + entries: vec!["projects".into(), ".config".into()], + truncated: true, + }; + assert_eq!(listing, round_trip(&listing)); + + // Older daemons omit `truncated`; it must default to false. + let json = serde_json::json!({ + "path": "/home/naps62", + "entries": ["projects"] + }); + let parsed: DirListing = serde_json::from_value(json).unwrap(); + assert!(!parsed.truncated); + } + + #[test] + fn daemon_list_dir_round_trips_and_tag() { + let req = DaemonInbound::ListDir { + reply_to: Uuid::from_u128(61), + path: "~/projects".into(), + }; + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["type"], "ListDir"); + assert_eq!(v["path"], "~/projects"); + assert_eq!(req, round_trip(&req)); + + let res = DaemonOutbound::ListDirResult { + reply_to: Uuid::from_u128(61), + result: RpcResult::Ok { + value: DirListing { + path: PathBuf::from("/home/naps62/projects"), + entries: vec!["maestro".into()], + truncated: false, + }, + }, + }; + let v = serde_json::to_value(&res).unwrap(); + assert_eq!(v["type"], "ListDirResult"); + assert_eq!(res, round_trip(&res)); + } + + #[test] + fn daemon_forward_list_dir_round_trips_and_tag() { + let fwd = DaemonOutbound::ForwardListDir { + reply_to: Uuid::from_u128(62), + daemon_id: "konishi".into(), + path: "/work".into(), + }; + let v = serde_json::to_value(&fwd).unwrap(); + assert_eq!(v["type"], "ForwardListDir"); + assert_eq!(v["daemon_id"], "konishi"); + assert_eq!(fwd, round_trip(&fwd)); + + let res = DaemonInbound::ForwardListDirResult { + reply_to: Uuid::from_u128(62), + result: RpcResult::Err { + error: ErrorInfo { + code: "permission_denied".into(), + message: "EACCES".into(), + }, + }, + }; + let v = serde_json::to_value(&res).unwrap(); + assert_eq!(v["type"], "ForwardListDirResult"); + assert_eq!(res, round_trip(&res)); + } + #[test] fn daemon_outbound_relay_ack_round_trip_and_tag() { let msg = DaemonOutbound::RelayAck {