feat(maestro): extend Heartbeat with perimeter_id + private_addr (NAP-242)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 17:46:05 +00:00
co-authored by Claude Opus 4.7
parent f0510a5996
commit 9a8109559b
3 changed files with 81 additions and 4 deletions
+11 -1
View File
@@ -165,8 +165,13 @@ impl RegistryClient {
} }
} }
_ = hb.tick() => { _ = 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( sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::Heartbeat, &DaemonOutbound::Heartbeat {
perimeter_id: None,
private_addr: None,
},
)?)).await?; )?)).await?;
} }
} }
@@ -208,6 +213,11 @@ impl RegistryClient {
)?)) )?))
.await?; .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(()) Ok(())
} }
+61 -2
View File
@@ -113,7 +113,15 @@ pub enum DaemonOutbound {
SessionUpdated { SessionUpdated {
session: Session, 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_addr: Option<String>,
},
CreateSessionResult { CreateSessionResult {
reply_to: ReplyTo, reply_to: ReplyTo,
result: RpcResult<Session>, result: RpcResult<Session>,
@@ -426,7 +434,14 @@ mod tests {
DaemonOutbound::SessionUpdated { DaemonOutbound::SessionUpdated {
session: session.clone(), 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 { DaemonOutbound::CreateSessionResult {
reply_to, reply_to,
result: RpcResult::Ok { result: RpcResult::Ok {
@@ -569,6 +584,50 @@ mod tests {
assert_eq!(json["type"], "DaemonRemoved"); 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] #[test]
fn daemon_outbound_resolve_attach_round_trip_and_tag() { fn daemon_outbound_resolve_attach_round_trip_and_tag() {
let msg = DaemonOutbound::ResolveAttach { let msg = DaemonOutbound::ResolveAttach {
+9 -1
View File
@@ -139,8 +139,10 @@ async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool
DaemonOutbound::SessionUpdated { session } => { DaemonOutbound::SessionUpdated { session } => {
registry.update_session(daemon_id, session).await; registry.update_session(daemon_id, session).await;
} }
DaemonOutbound::Heartbeat => { DaemonOutbound::Heartbeat { .. } => {
// Per-read timeout above is the heartbeat watchdog; no extra work. // 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 } => { DaemonOutbound::CreateSessionResult { reply_to, result } => {
registry.complete_create(reply_to, result).await; 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 } => { DaemonOutbound::KillSessionResult { reply_to, result } => {
registry.complete_kill(reply_to, result).await; 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 true
} }