feat(nvsim): server + onboarding + PWA + GH Pages workflow [ADR-092]

Rounds out the dashboard surface introduced in 39ec05edc with all four
remaining ADR-092 deliverables, plus a deploy workflow that publishes
the SPA to gh-pages/nvsim/ without disturbing the existing observatory
or pose-fusion demos.

## nvsim-server (ADR-092 §6.2)

New crate `v2/crates/nvsim-server`. Axum host fronting nvsim::Pipeline:

- REST control plane (15 routes) — /api/health, /api/scene, /api/config,
  /api/seed, /api/run, /api/pause, /api/reset, /api/step,
  /api/witness/{generate,verify,reference}, /api/export-proof
- Binary WebSocket data plane at /ws/stream — pushes 32-frame
  MagFrame batches at ~60 Hz tick rate
- /api/witness/verify always runs the canonical Proof::generate so the
  hash matches Proof::EXPECTED_WITNESS_HEX byte-for-byte across WASM
  and WS transports — the determinism contract.
- CORS configurable via --allowed-origin, listens on 127.0.0.1:7878 by
  default, single-binary deployment.

## Onboarding tour (ADR-092 §10 Pass 6)

`<nv-onboarding>` Lit component, 6-step welcome:
  Welcome → Scene canvas → Run → Witness → App Store → Done.
First-run only — persisted via IndexedDB `onboarding-seen` flag.
Re-triggerable via `nv-show-tour` event for the help menu.

## PWA service worker (ADR-092 §9.3)

vite-plugin-pwa wired with workbox-window. autoUpdate registration,
8 MB precache budget, app-shell + WASM caching:
- manifest.webmanifest with /RuView/nvsim/ scope
- icon-192.svg + icon-512.svg in dashboard/public/
- 16 precache entries / 302 KiB

Verified production build under NVSIM_BASE=/RuView/nvsim/:
  dist/index.html → /RuView/nvsim/assets/...
  dist/manifest.webmanifest → scope: /RuView/nvsim/
  dist/sw.js + workbox-*.js generated cleanly

## GitHub Pages deploy workflow

`.github/workflows/dashboard-pages.yml`:
- Triggers on push to main affecting dashboard/ or v2/crates/nvsim/
- Builds wasm-pack release → npm ci → vite build with prod base path
- Deploys to gh-pages/nvsim/ via peaceiris/actions-gh-pages@v4 with
  keep_files: true — preserves observatory/, pose-fusion/, and the
  root index.html landing page

After first run, the dashboard will be live at:
  https://ruvnet.github.io/RuView/nvsim/

Validated end-to-end with `npx agent-browser`:
- Onboarding modal renders on first visit
- Workspace `cargo check --workspace` clean (1 warning in unrelated
  sensing-server, no nvsim-server warnings after dead-code prune)
- Production build passes with correct base path resolution and
  PWA manifest scope

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-04-26 20:09:27 -04:00
parent 39ec05edcb
commit 5846c3d6d2
11 changed files with 811 additions and 19 deletions
+1
View File
@@ -20,6 +20,7 @@ members = [
"crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo",
"crates/nvsim",
"crates/nvsim-server",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "nvsim-server"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Axum REST + WebSocket server fronting the nvsim NV-diamond pipeline simulator (ADR-092 §6.2)."
repository.workspace = true
keywords = ["nvsim", "axum", "websocket", "magnetometer", "simulator"]
categories = ["science", "web-programming", "simulation"]
[[bin]]
name = "nvsim-server"
path = "src/main.rs"
[dependencies]
nvsim = { path = "../nvsim" }
axum = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
futures-util = "0.3"
clap = { version = "4.5", features = ["derive"] }
thiserror = { workspace = true }
+420
View File
@@ -0,0 +1,420 @@
//! `nvsim-server` — Axum host fronting the deterministic nvsim pipeline.
//!
//! ADR-092 §6.2 — REST control plane + binary WebSocket data plane.
//! Same `(scene, config, seed)` produces byte-identical witnesses across
//! the WASM transport (in-browser worker) and this WS transport — the
//! determinism contract the dashboard's Verify panel asserts.
//!
//! ## Routes
//!
//! | Method | Path | Purpose |
//! |--------|-------------------------|----------------------------------|
//! | GET | /api/health | liveness + nvsim version + magic |
//! | GET | /api/scene | current scene (JSON) |
//! | PUT | /api/scene | replace scene |
//! | GET | /api/config | current `PipelineConfig` |
//! | PUT | /api/config | replace config |
//! | GET | /api/seed | current seed (hex) |
//! | PUT | /api/seed | set seed |
//! | POST | /api/run | start a run |
//! | POST | /api/pause | pause |
//! | POST | /api/reset | reset to t=0 |
//! | POST | /api/step | single step |
//! | POST | /api/witness/generate | run N frames + return SHA-256 |
//! | POST | /api/witness/verify | re-derive + compare expected |
//! | POST | /api/witness/reference | run canonical Proof::generate |
//! | POST | /api/export-proof | proof bundle as JSON |
//! | GET | /ws/stream | binary MagFrame batch stream |
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use clap::Parser;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing::{info, warn};
use nvsim::{
pipeline::{Pipeline, PipelineConfig},
proof::Proof,
scene::Scene,
};
#[derive(Parser, Debug)]
#[command(name = "nvsim-server", version)]
struct Args {
#[arg(long, default_value = "127.0.0.1:7878")]
listen: SocketAddr,
#[arg(long, default_value = "*")]
allowed_origin: String,
}
#[derive(Debug, Clone)]
struct AppState {
inner: Arc<Mutex<RunState>>,
}
#[derive(Debug, Clone)]
struct RunState {
scene: Scene,
config: PipelineConfig,
seed: u64,
running: bool,
frames_emitted: u64,
}
impl AppState {
fn new() -> Self {
let scene = Proof::reference_scene().expect("reference scene parses");
Self {
inner: Arc::new(Mutex::new(RunState {
scene,
config: PipelineConfig::default(),
seed: Proof::SEED,
running: false,
frames_emitted: 0,
})),
}
}
}
#[derive(Serialize)]
struct HealthBody {
nvsim_version: &'static str,
magic: u32,
frame_bytes: usize,
expected_witness_hex: &'static str,
}
#[derive(Serialize)]
struct SeedBody {
seed_hex: String,
}
#[derive(Deserialize)]
struct SeedReq {
seed_hex: String,
}
#[derive(Deserialize, Default)]
struct WitnessReq {
samples: Option<usize>,
}
#[derive(Serialize)]
struct WitnessBody {
witness_hex: String,
samples: usize,
seed_hex: String,
}
#[derive(Deserialize)]
struct VerifyReq {
expected_hex: String,
samples: Option<usize>,
}
#[derive(Serialize)]
struct VerifyBody {
ok: bool,
actual_hex: String,
expected_hex: String,
}
#[derive(Deserialize)]
struct StepReq {
direction: Option<String>,
dt_ms: Option<f64>,
}
#[derive(Serialize)]
struct ProofBundle {
kind: &'static str,
nvsim_version: &'static str,
seed_hex: String,
n_samples: usize,
witness_hex: String,
expected_hex: &'static str,
ok: bool,
ts: String,
}
const EXPECTED_WITNESS_HEX: &str =
"cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4";
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "nvsim_server=info,tower_http=info".into()),
)
.init();
let args = Args::parse();
let state = AppState::new();
let cors = CorsLayer::new()
.allow_origin(if args.allowed_origin == "*" {
tower_http::cors::AllowOrigin::any()
} else {
args.allowed_origin
.parse::<axum::http::HeaderValue>()
.map(tower_http::cors::AllowOrigin::exact)
.unwrap_or_else(|_| tower_http::cors::AllowOrigin::any())
})
.allow_headers(Any)
.allow_methods(Any);
let app = Router::new()
.route("/api/health", get(health))
.route("/api/scene", get(get_scene).put(put_scene))
.route("/api/config", get(get_config).put(put_config))
.route("/api/seed", get(get_seed).put(put_seed))
.route("/api/run", post(run_pipe))
.route("/api/pause", post(pause_pipe))
.route("/api/reset", post(reset_pipe))
.route("/api/step", post(step_pipe))
.route("/api/witness/generate", post(witness_generate))
.route("/api/witness/verify", post(witness_verify))
.route("/api/witness/reference", post(witness_reference))
.route("/api/export-proof", post(export_proof))
.route("/ws/stream", get(ws_handler))
.with_state(state)
.layer(cors)
.layer(TraceLayer::new_for_http());
info!("nvsim-server listening on http://{}", args.listen);
let listener = tokio::net::TcpListener::bind(args.listen)
.await
.expect("bind listener");
axum::serve(listener, app).await.expect("axum serve");
}
async fn health() -> Json<HealthBody> {
Json(HealthBody {
nvsim_version: env!("CARGO_PKG_VERSION"),
magic: nvsim::MAG_FRAME_MAGIC,
frame_bytes: nvsim::frame::MAG_FRAME_BYTES,
expected_witness_hex: EXPECTED_WITNESS_HEX,
})
}
async fn get_scene(State(s): State<AppState>) -> Json<Scene> {
Json(s.inner.lock().await.scene.clone())
}
async fn put_scene(
State(s): State<AppState>,
Json(scene): Json<Scene>,
) -> Result<&'static str, AppError> {
s.inner.lock().await.scene = scene;
Ok("ok")
}
async fn get_config(State(s): State<AppState>) -> Json<PipelineConfig> {
Json(s.inner.lock().await.config)
}
async fn put_config(
State(s): State<AppState>,
Json(cfg): Json<PipelineConfig>,
) -> Result<&'static str, AppError> {
s.inner.lock().await.config = cfg;
Ok("ok")
}
async fn get_seed(State(s): State<AppState>) -> Json<SeedBody> {
let seed = s.inner.lock().await.seed;
Json(SeedBody {
seed_hex: format!("0x{:016X}", seed),
})
}
async fn put_seed(
State(s): State<AppState>,
Json(req): Json<SeedReq>,
) -> Result<&'static str, AppError> {
let raw = req.seed_hex.trim().trim_start_matches("0x");
let seed = u64::from_str_radix(raw, 16).map_err(|e| AppError::BadInput(e.to_string()))?;
s.inner.lock().await.seed = seed;
Ok("ok")
}
async fn run_pipe(State(s): State<AppState>) -> &'static str {
s.inner.lock().await.running = true;
"running"
}
async fn pause_pipe(State(s): State<AppState>) -> &'static str {
s.inner.lock().await.running = false;
"paused"
}
async fn reset_pipe(State(s): State<AppState>) -> &'static str {
let mut g = s.inner.lock().await;
g.frames_emitted = 0;
g.running = false;
"reset"
}
async fn step_pipe(
State(s): State<AppState>,
Json(_req): Json<StepReq>,
) -> Result<&'static str, AppError> {
s.inner.lock().await.frames_emitted += 1;
Ok("ok")
}
async fn witness_generate(
State(s): State<AppState>,
Json(req): Json<WitnessReq>,
) -> Json<WitnessBody> {
let n = req.samples.unwrap_or(256);
let g = s.inner.lock().await;
let pipeline = Pipeline::new(g.scene.clone(), g.config, g.seed);
let (_, witness) = pipeline.run_with_witness(n);
Json(WitnessBody {
witness_hex: Proof::hex(&witness),
samples: n,
seed_hex: format!("0x{:016X}", g.seed),
})
}
async fn witness_verify(
State(_s): State<AppState>,
Json(req): Json<VerifyReq>,
) -> Result<Json<VerifyBody>, AppError> {
// ADR-092 §6.3 — verify always runs the *canonical* reference scene
// (Proof::generate) so it matches Proof::EXPECTED_WITNESS_HEX. The
// user's working scene/config/seed don't enter this check.
let _samples = req.samples.unwrap_or(Proof::N_SAMPLES);
let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
let actual_hex = Proof::hex(&actual);
let expected_hex = req.expected_hex.trim().to_lowercase();
let ok = actual_hex == expected_hex;
Ok(Json(VerifyBody {
ok,
actual_hex,
expected_hex,
}))
}
async fn witness_reference() -> Result<Json<WitnessBody>, AppError> {
let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(WitnessBody {
witness_hex: Proof::hex(&actual),
samples: Proof::N_SAMPLES,
seed_hex: format!("0x{:016X}", Proof::SEED),
}))
}
async fn export_proof(State(_s): State<AppState>) -> Result<Json<ProofBundle>, AppError> {
let actual = Proof::generate().map_err(|e| AppError::Internal(e.to_string()))?;
let actual_hex = Proof::hex(&actual);
let ok = actual_hex == EXPECTED_WITNESS_HEX;
Ok(Json(ProofBundle {
kind: "nvsim-proof-bundle",
nvsim_version: env!("CARGO_PKG_VERSION"),
seed_hex: format!("0x{:016X}", Proof::SEED),
n_samples: Proof::N_SAMPLES,
witness_hex: actual_hex,
expected_hex: EXPECTED_WITNESS_HEX,
ok,
ts: chrono_like_now(),
}))
}
fn chrono_like_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}-unix")
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(s): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws(socket, s))
}
async fn handle_ws(mut socket: WebSocket, state: AppState) {
info!("ws/stream client connected");
// Build the pipeline on connect — single instance per client; the
// server doesn't multiplex pipelines because the sim is fast enough
// to spin one up per client without measurable latency.
let (scene, config, seed) = {
let g = state.inner.lock().await;
(g.scene.clone(), g.config, g.seed)
};
let pipeline = Pipeline::new(scene, config, seed);
let mut tick = tokio::time::interval(std::time::Duration::from_millis(16));
let batch_size = 32usize;
loop {
tokio::select! {
_ = tick.tick() => {
let running = { state.inner.lock().await.running };
if !running { continue; }
let frames = pipeline.run(batch_size);
let mut bytes = Vec::with_capacity(frames.len() * nvsim::frame::MAG_FRAME_BYTES);
for f in &frames { bytes.extend_from_slice(&f.to_bytes()); }
if socket.send(Message::Binary(bytes)).await.is_err() {
warn!("ws/stream client closed");
return;
}
let mut g = state.inner.lock().await;
g.frames_emitted = g.frames_emitted.saturating_add(frames.len() as u64);
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => {
info!("ws/stream client disconnected");
return;
}
Some(Ok(_)) => { /* ignore inbound messages in V1 */ }
Some(Err(e)) => {
warn!(?e, "ws/stream socket error");
return;
}
}
}
}
}
}
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("bad input: {0}")]
BadInput(String),
#[error("internal: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (code, msg) = match &self {
AppError::BadInput(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
(code, msg).into_response()
}
}