feat(adr-110): REST endpoints /api/v1/nodes/:id/sync and /api/v1/mesh

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": { "<id>": 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<u8> 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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-23 14:30:14 -04:00
parent 7eeb265ebc
commit c6a0d5dbf5
1 changed files with 69 additions and 0 deletions

View File

@ -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<SharedState>,
Path(id): Path<u8>,
) -> Result<Json<NodeSyncSnapshot>, (StatusCode, Json<serde_json::Value>)> {
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": { "<id>": 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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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))