From c6a0d5dbf5be0f8234d2f57646437c3063c634b5 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 14:30:14 -0400 Subject: [PATCH] feat(adr-110): REST endpoints /api/v1/nodes/:id/sync and /api/v1/mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 29 — extends the iter 23 WebSocket NodeSyncSnapshot publication with an HTTP surface so non-streaming clients (curl scripts, Home Assistant REST sensors, Prometheus exporters, automation rule probes) can poll mesh state without holding a WebSocket connection. GET /api/v1/nodes/:id/sync 200 → Json(NodeSyncSnapshot) when latest_sync is present 404 → {"error": "unknown_node" | "no_sync", "node_id": N} — "no_sync" includes a `hint` pointing operators at the "no mesh peer or not v0.6.9+" diagnostic. GET /api/v1/mesh 200 → { "nodes": { "": NodeSyncSnapshot, ... }, "total": N } Nodes without a recent sync are omitted; an empty `nodes` object means no mesh peers reachable. Both handlers reuse the iter 23 NodeSyncSnapshot struct (same JSON shape as the WebSocket broadcast — clients get one schema, two delivery modes). The Path extractor returns 404 on overflow automatically (axum), so /api/v1/nodes/256/sync gives a clean error. cargo check -p wifi-densepose-sensing-server --no-default-features → green. Curl quick-start (added to operator playbook material in a follow-up): curl http://localhost:3000/api/v1/mesh # full fleet curl http://localhost:3000/api/v1/nodes/9/sync # one node Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/main.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 7eeeb060..6cc2a4fd 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4056,6 +4056,72 @@ async fn sona_activate( } /// GET /api/v1/nodes — per-node health and feature info. +/// ADR-110 iter 29 — per-node mesh sync snapshot via HTTP. +/// +/// GET /api/v1/nodes/:id/sync +/// 200 → Json(NodeSyncSnapshot) when latest_sync is present +/// 404 → {"error": "no_sync", "node_id": N} otherwise +/// +/// Complements the WebSocket `sync` field (iter 23) for clients that +/// can't hold a streaming connection (curl scripts, Home Assistant REST +/// sensors, automation rule probes). +async fn node_sync_endpoint( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let s = state.read().await; + let ns = s.node_states.get(&id).ok_or_else(|| { + (StatusCode::NOT_FOUND, Json(serde_json::json!({ + "error": "unknown_node", "node_id": id, + }))) + })?; + let sync = ns.latest_sync.as_ref().ok_or_else(|| { + (StatusCode::NOT_FOUND, Json(serde_json::json!({ + "error": "no_sync", "node_id": id, + "hint": "node hasn't emitted a sync packet yet (no mesh peer or not v0.6.9+)", + }))) + })?; + Ok(Json(NodeSyncSnapshot { + offset_us: sync.local_minus_epoch_us(), + is_leader: sync.flags.is_leader, + is_valid: sync.flags.is_valid, + smoothed: sync.flags.smoothed_used, + sequence: sync.sequence, + csi_fps_ema: ns.csi_fps_ema, + csi_fps_samples: ns.csi_fps_samples, + })) +} + +/// ADR-110 iter 29 — fleet-wide mesh state via HTTP. +/// +/// GET /api/v1/mesh +/// 200 → { "nodes": { "": NodeSyncSnapshot, ... }, "total": N } +/// Nodes without a recent sync are omitted from the map; an empty +/// `nodes` object means no mesh peers reachable. +async fn mesh_endpoint(State(state): State) -> Json { + let s = state.read().await; + let mut nodes = serde_json::Map::new(); + for (&id, ns) in s.node_states.iter() { + if let Some(sync) = ns.latest_sync.as_ref() { + let snap = NodeSyncSnapshot { + offset_us: sync.local_minus_epoch_us(), + is_leader: sync.flags.is_leader, + is_valid: sync.flags.is_valid, + smoothed: sync.flags.smoothed_used, + sequence: sync.sequence, + csi_fps_ema: ns.csi_fps_ema, + csi_fps_samples: ns.csi_fps_samples, + }; + nodes.insert(id.to_string(), serde_json::to_value(snap).unwrap()); + } + } + let total = nodes.len(); + Json(serde_json::json!({ + "nodes": serde_json::Value::Object(nodes), + "total": total, + })) +} + async fn nodes_endpoint(State(state): State) -> Json { let s = state.read().await; let now = std::time::Instant::now(); @@ -5566,6 +5632,9 @@ async fn main() { .route("/api/v1/sensing/latest", get(latest)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) + // ADR-110 iter 29 — per-node mesh sync state for HTTP clients. + .route("/api/v1/nodes/:id/sync", get(node_sync_endpoint)) + .route("/api/v1/mesh", get(mesh_endpoint)) // Vital sign endpoints .route("/api/v1/vital-signs", get(vital_signs_endpoint)) .route("/api/v1/edge-vitals", get(edge_vitals_endpoint))