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:
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user