From 9a8109559bfd00a1535db65d7130e06f961d43ff Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Tue, 26 May 2026 17:46:05 +0000 Subject: [PATCH] feat(maestro): extend Heartbeat with perimeter_id + private_addr (NAP-242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert DaemonOutbound::Heartbeat from unit to struct variant with two optional fields advertising the daemon's T2 reachability. Old payloads ({"type":"Heartbeat"} with no fields) still parse — both fields default to None — and a new daemon that doesn't populate them serializes back to the same minimal shape. Update the daemon's heartbeat ticker and the registry's match arm to the new shape. Population from daemon config + registry persistence lands with the broker logic in NAP-243. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/maestro-daemon/src/registry_client.rs | 12 +++- crates/maestro-protocol/src/lib.rs | 63 +++++++++++++++++++- crates/maestro-registry/src/handler.rs | 10 +++- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/maestro-daemon/src/registry_client.rs b/crates/maestro-daemon/src/registry_client.rs index e8d73f5..bd2442e 100644 --- a/crates/maestro-daemon/src/registry_client.rs +++ b/crates/maestro-daemon/src/registry_client.rs @@ -165,8 +165,13 @@ impl RegistryClient { } } _ = hb.tick() => { + // perimeter_id / private_addr are wired from daemon config + // in NAP-243; NAP-242 lands the protocol field only. sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::Heartbeat, + &DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }, )?)).await?; } } @@ -208,6 +213,11 @@ impl RegistryClient { )?)) .await?; } + DaemonInbound::ResolveAttachResult { .. } | DaemonInbound::RelaySetup { .. } => { + // Wired by NAP-243 (broker mode). Lands here only so registries + // emitting these messages don't crash older daemons on parse. + tracing::debug!("broker message received but unhandled (NAP-243 pending)"); + } } Ok(()) } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index d866a01..66cd438 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -113,7 +113,15 @@ pub enum DaemonOutbound { SessionUpdated { session: Session, }, - Heartbeat, + /// Liveness ticker. The optional `perimeter_id` / `private_addr` advertise + /// the daemon's reachability for the T2 transport (NAP-242). Old daemons + /// that don't set them still produce the bare `{"type":"Heartbeat"}` shape. + Heartbeat { + #[serde(default, skip_serializing_if = "Option::is_none")] + perimeter_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + private_addr: Option, + }, CreateSessionResult { reply_to: ReplyTo, result: RpcResult, @@ -426,7 +434,14 @@ mod tests { DaemonOutbound::SessionUpdated { session: session.clone(), }, - DaemonOutbound::Heartbeat, + DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }, + DaemonOutbound::Heartbeat { + perimeter_id: Some("wg-naps62".into()), + private_addr: Some("10.0.0.7:7070".into()), + }, DaemonOutbound::CreateSessionResult { reply_to, result: RpcResult::Ok { @@ -569,6 +584,50 @@ mod tests { assert_eq!(json["type"], "DaemonRemoved"); } + #[test] + fn heartbeat_legacy_payload_parses_with_none_fields() { + // Daemons predating NAP-242 send the bare `{"type":"Heartbeat"}` shape. + // It must still deserialize, with both new fields defaulting to None. + let legacy = serde_json::json!({ "type": "Heartbeat" }); + let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap(); + match parsed { + DaemonOutbound::Heartbeat { + perimeter_id, + private_addr, + } => { + assert!(perimeter_id.is_none()); + assert!(private_addr.is_none()); + } + other => panic!("expected Heartbeat, got {other:?}"), + } + } + + #[test] + fn heartbeat_empty_serializes_to_legacy_shape() { + // A new daemon that doesn't set perimeter/addr must produce wire bytes + // identical to an old daemon — registries doing equality checks or + // logging payloads see no surprise. + let hb = DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }; + let v = serde_json::to_value(&hb).unwrap(); + assert_eq!(v, serde_json::json!({ "type": "Heartbeat" })); + } + + #[test] + fn heartbeat_with_fields_round_trips() { + let hb = DaemonOutbound::Heartbeat { + perimeter_id: Some("wg-naps62".into()), + private_addr: Some("10.0.0.7:7070".into()), + }; + let v = serde_json::to_value(&hb).unwrap(); + assert_eq!(v["type"], "Heartbeat"); + assert_eq!(v["perimeter_id"], "wg-naps62"); + assert_eq!(v["private_addr"], "10.0.0.7:7070"); + assert_eq!(hb, round_trip(&hb)); + } + #[test] fn daemon_outbound_resolve_attach_round_trip_and_tag() { let msg = DaemonOutbound::ResolveAttach { diff --git a/crates/maestro-registry/src/handler.rs b/crates/maestro-registry/src/handler.rs index 0baa1f1..d34b1fa 100644 --- a/crates/maestro-registry/src/handler.rs +++ b/crates/maestro-registry/src/handler.rs @@ -139,8 +139,10 @@ async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool DaemonOutbound::SessionUpdated { session } => { registry.update_session(daemon_id, session).await; } - DaemonOutbound::Heartbeat => { + DaemonOutbound::Heartbeat { .. } => { // Per-read timeout above is the heartbeat watchdog; no extra work. + // perimeter_id / private_addr advertised here are persisted by + // NAP-243 for the T2 transport. } DaemonOutbound::CreateSessionResult { reply_to, result } => { registry.complete_create(reply_to, result).await; @@ -148,6 +150,12 @@ async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool DaemonOutbound::KillSessionResult { reply_to, result } => { registry.complete_kill(reply_to, result).await; } + DaemonOutbound::ResolveAttach { reply_to, .. } + | DaemonOutbound::RelayAck { reply_to, .. } => { + // Handled by NAP-243 (broker mode); type already lands here so old + // daemons sending it don't kill the connection on parse failure. + tracing::debug!(%daemon_id, ?reply_to, "broker message received but unhandled (NAP-243 pending)"); + } } true }