From 5846c3d6d2db6e478b47e0812d23a7b4ddab3324 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 26 Apr 2026 20:09:27 -0400 Subject: [PATCH] feat(nvsim): server + onboarding + PWA + GH Pages workflow [ADR-092] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) `` 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 --- .github/workflows/dashboard-pages.yml | 85 +++++ dashboard/package-lock.json | 25 +- dashboard/package.json | 5 +- dashboard/public/icon-192.svg | 4 + dashboard/public/icon-512.svg | 10 + dashboard/src/components/nv-app.ts | 2 + dashboard/src/components/nv-onboarding.ts | 203 +++++++++++ dashboard/vite.config.ts | 47 ++- v2/Cargo.toml | 1 + v2/crates/nvsim-server/Cargo.toml | 28 ++ v2/crates/nvsim-server/src/main.rs | 420 ++++++++++++++++++++++ 11 files changed, 811 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/dashboard-pages.yml create mode 100644 dashboard/public/icon-192.svg create mode 100644 dashboard/public/icon-512.svg create mode 100644 dashboard/src/components/nv-onboarding.ts create mode 100644 v2/crates/nvsim-server/Cargo.toml create mode 100644 v2/crates/nvsim-server/src/main.rs diff --git a/.github/workflows/dashboard-pages.yml b/.github/workflows/dashboard-pages.yml new file mode 100644 index 00000000..d484e048 --- /dev/null +++ b/.github/workflows/dashboard-pages.yml @@ -0,0 +1,85 @@ +name: nvsim Dashboard → GitHub Pages + +# Deploys the nvsim Vite/Lit dashboard to gh-pages/nvsim/ — preserving +# the existing observatory/, pose-fusion/, and root index.html demos +# already published from gh-pages. ADR-092 §9. + +on: + push: + branches: [main] + paths: + - 'v2/crates/nvsim/**' + - 'dashboard/**' + - '.github/workflows/dashboard-pages.yml' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: dashboard-pages + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + + - name: Install Rust + wasm32 target + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + v2/target + key: ${{ runner.os }}-cargo-nvsim-${{ hashFiles('v2/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-nvsim- + + - name: Install wasm-pack + run: cargo install wasm-pack --locked --version 0.13.x || true + + - name: Build nvsim WASM + working-directory: v2 + run: | + wasm-pack build crates/nvsim \ + --target web \ + --out-dir ../../dashboard/public/nvsim-pkg \ + --release \ + -- --no-default-features --features wasm + + - name: Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Install dashboard deps + working-directory: dashboard + run: npm ci + + - name: Build dashboard + working-directory: dashboard + env: + NVSIM_BASE: /RuView/nvsim/ + run: npm run build + + - name: Deploy to gh-pages/nvsim/ + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dashboard/dist + destination_dir: nvsim + # CRITICAL: preserves observatory/, pose-fusion/, root index.html + # and any other RuView demos already on gh-pages. + keep_files: true + commit_message: 'deploy(nvsim): ${{ github.sha }}' + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 0426b91d..359c9b7f 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,12 +9,13 @@ "version": "0.1.0", "dependencies": { "@preact/signals-core": "^1.8.0", - "lit": "^3.2.1" + "lit": "^3.2.1", + "workbox-window": "^7.4.0" }, "devDependencies": { "typescript": "^5.6.3", "vite": "^5.4.10", - "vite-plugin-pwa": "^0.21.1", + "vite-plugin-pwa": "^1.2.0", "vitest": "^2.1.4" } }, @@ -5861,17 +5862,17 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", - "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", - "workbox-build": "^7.3.0", - "workbox-window": "^7.3.0" + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" }, "engines": { "node": ">=16.0.0" @@ -5880,10 +5881,10 @@ "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@vite-pwa/assets-generator": "^0.2.6", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "workbox-build": "^7.3.0", - "workbox-window": "^7.3.0" + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" }, "peerDependenciesMeta": { "@vite-pwa/assets-generator": { @@ -6304,7 +6305,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", - "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { @@ -6420,7 +6420,6 @@ "version": "7.4.0", "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", - "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", diff --git a/dashboard/package.json b/dashboard/package.json index 91c00bd2..490b3677 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,12 +14,13 @@ }, "dependencies": { "@preact/signals-core": "^1.8.0", - "lit": "^3.2.1" + "lit": "^3.2.1", + "workbox-window": "^7.4.0" }, "devDependencies": { "typescript": "^5.6.3", "vite": "^5.4.10", - "vite-plugin-pwa": "^0.21.1", + "vite-plugin-pwa": "^1.2.0", "vitest": "^2.1.4" } } diff --git a/dashboard/public/icon-192.svg b/dashboard/public/icon-192.svg new file mode 100644 index 00000000..a378624b --- /dev/null +++ b/dashboard/public/icon-192.svg @@ -0,0 +1,4 @@ + + + NV + diff --git a/dashboard/public/icon-512.svg b/dashboard/public/icon-512.svg new file mode 100644 index 00000000..67372c10 --- /dev/null +++ b/dashboard/public/icon-512.svg @@ -0,0 +1,10 @@ + + + + + + + + + NV + diff --git a/dashboard/src/components/nv-app.ts b/dashboard/src/components/nv-app.ts index 5cbba7df..8e54ba8a 100644 --- a/dashboard/src/components/nv-app.ts +++ b/dashboard/src/components/nv-app.ts @@ -16,6 +16,7 @@ import './nv-modal'; import './nv-palette'; import './nv-debug-hud'; import './nv-settings-drawer'; +import './nv-onboarding'; export type View = 'scene' | 'apps' | 'settings'; @@ -87,6 +88,7 @@ export class NvApp extends LitElement { + `; } } diff --git a/dashboard/src/components/nv-onboarding.ts b/dashboard/src/components/nv-onboarding.ts new file mode 100644 index 00000000..c219b0d6 --- /dev/null +++ b/dashboard/src/components/nv-onboarding.ts @@ -0,0 +1,203 @@ +/* First-run welcome tour. 5 steps walking the user through the + * dashboard's main concepts. Persists `seen` flag in IndexedDB so it + * only shows the first time. ADR-092 §10 Pass 6. + */ + +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { kvGet, kvSet } from '../store/persistence'; + +interface TourStep { + title: string; + body: string; + cta?: string; +} + +const STEPS: TourStep[] = [ + { + title: 'Welcome to nvsim', + body: `

nvsim is an open-source, deterministic forward simulator for + nitrogen-vacancy diamond magnetometry — a real Rust crate compiled to + WASM and running in your browser, right now.

+

This 30-second tour highlights the four panels you'll use most.

`, + cta: 'Start tour', + }, + { + title: '1. Scene canvas', + body: `

The middle panel shows your magnetic scene — sources you can + drag (rebar, heart proxy, mains hum, ferrous door) and a single NV-diamond + sensor in the centre. Field lines from each source connect to the sensor + and animate while the pipeline runs.

+

Click 2 on your keyboard any time to jump to the Frame inspector.

`, + }, + { + title: '2. Run the pipeline', + body: `

Click the ▶ Run button (top-right) to start streaming + MagFrame records at the digitiser's sample rate. The B-vector + trace and Frame stream sparkline update live, and the FPS pill in the + topbar shows the simulator's throughput in kHz.

+

Space toggles run/pause from anywhere.

`, + }, + { + title: '3. Witness panel', + body: `

The Witness tab is the heart of nvsim's determinism contract. + Click Verify and the pipeline re-derives the SHA-256 over a 256-frame + reference run and asserts it matches the constant pinned in the Rust crate.

+

Same input → same hash → byte-for-byte across browsers, OSes, transports. + If the hash drifts, your build is non-canonical.

`, + }, + { + title: '4. App Store', + body: `

The grid icon on the left rail opens the App Store — every + hot-loadable WASM edge module RuView ships, plus the simulators. 66 apps + across 13 categories: medical, security, building, retail, industrial, + signal, learning, autonomy, and more.

+

Toggle any card to mark it active in this session; the WS transport + will push the activation set to a connected ESP32 mesh.

`, + }, + { + title: 'You are ready', + body: `

Press ⌘K (or Ctrl K) any time for the command + palette, ? for the full shortcuts list, or just start clicking.

+

Source on GitHub: + github.com/ruvnet/RuView · ADR-089, ADR-092 · MIT/Apache-2.0.

`, + cta: 'Get started', + }, +]; + +@customElement('nv-onboarding') +export class NvOnboarding extends LitElement { + @state() private open = false; + @state() private step = 0; + + static styles = css` + :host { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 240; + display: grid; place-items: center; + opacity: 0; pointer-events: none; + transition: opacity 0.18s; + } + :host([open]) { opacity: 1; pointer-events: auto; } + .card { + background: var(--bg-1); + border: 1px solid var(--line-2); + border-radius: var(--radius); + box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7); + width: min(560px, 92vw); + max-height: 86vh; + display: flex; flex-direction: column; + transform: translateY(12px) scale(0.98); + transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1); + } + :host([open]) .card { transform: translateY(0) scale(1); } + .h { + padding: 20px 22px 8px; + display: flex; justify-content: space-between; align-items: flex-start; + } + .h h2 { margin: 0; font-size: 18px; letter-spacing: -0.01em; } + .body { + padding: 8px 22px 16px; + font-size: 13px; color: var(--ink-2); line-height: 1.55; + overflow-y: auto; + } + .body p { margin: 0 0 12px; } + .body code, .body kbd { + font-family: var(--mono); font-size: 11.5px; + padding: 1px 5px; background: var(--bg-3); + border: 1px solid var(--line); border-radius: 4px; + color: var(--accent); + } + .footer { + display: flex; align-items: center; gap: 12px; + padding: 12px 22px; + border-top: 1px solid var(--line); + } + .dots { display: flex; gap: 6px; flex: 1; } + .dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--bg-3); border: 1px solid var(--line-2); + } + .dot.active { background: var(--accent); border-color: var(--accent); } + button { + padding: 8px 14px; + border-radius: 8px; + font-size: 12.5px; font-weight: 500; + border: 1px solid var(--line); + background: var(--bg-2); color: var(--ink); + cursor: pointer; + font-family: inherit; + } + button.primary { + background: var(--accent); border-color: var(--accent); + color: #1a0f00; + } + button.ghost { background: transparent; } + .skip { + width: 28px; height: 28px; + background: transparent; border: 1px solid var(--line); + border-radius: 6px; color: var(--ink-2); + } + `; + + override async connectedCallback(): Promise { + super.connectedCallback(); + window.addEventListener('nv-show-tour', this.show as EventListener); + const seen = await kvGet('onboarding-seen'); + if (!seen) { + this.open = true; + this.setAttribute('open', ''); + } + } + override disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener('nv-show-tour', this.show as EventListener); + } + + private show = (): void => { + this.step = 0; + this.open = true; + this.setAttribute('open', ''); + }; + + private async dismiss(): Promise { + this.open = false; + this.removeAttribute('open'); + await kvSet('onboarding-seen', true); + } + + private next(): void { + if (this.step < STEPS.length - 1) this.step++; + else void this.dismiss(); + } + + private prev(): void { + if (this.step > 0) this.step--; + } + + override render() { + const s = STEPS[this.step]; + return html` + + `; + } +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 84d02c01..9f9a2dff 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; // Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker. // Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable @@ -11,6 +12,48 @@ export default defineConfig({ worker: { format: 'es', }, + plugins: [ + VitePWA({ + registerType: 'autoUpdate', + includeAssets: [ + 'nvsim-pkg/nvsim.js', + 'nvsim-pkg/nvsim_bg.wasm', + ], + manifest: { + name: 'nvsim — NV-Diamond Magnetometer Simulator', + short_name: 'nvsim', + description: 'Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs.', + theme_color: '#0d1117', + background_color: '#0d1117', + display: 'standalone', + scope: base, + start_url: base, + icons: [ + { + src: 'icon-192.svg', + sizes: '192x192', + type: 'image/svg+xml', + purpose: 'any maskable', + }, + { + src: 'icon-512.svg', + sizes: '512x512', + type: 'image/svg+xml', + purpose: 'any maskable', + }, + ], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,svg,wasm,woff,woff2}'], + // WASM is large; bump the precache size budget so workbox doesn't + // skip nvsim_bg.wasm. + maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, + }, + devOptions: { + enabled: false, + }, + }), + ], build: { target: 'es2022', sourcemap: true, @@ -27,13 +70,9 @@ export default defineConfig({ port: 5173, strictPort: true, fs: { - // wasm-pack output sits in public/nvsim-pkg; vite already serves it, - // but allow fs reads from the workspace root for HMR convenience. allow: ['..', '.'], }, headers: { - // SAB ring buffer is opt-in; these headers are no-op without crossOriginIsolated - // but make local dev parity with a future CORS-isolated host. 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 7005fc5f..113859cd 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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`. diff --git a/v2/crates/nvsim-server/Cargo.toml b/v2/crates/nvsim-server/Cargo.toml new file mode 100644 index 00000000..10e2ce3d --- /dev/null +++ b/v2/crates/nvsim-server/Cargo.toml @@ -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 } diff --git a/v2/crates/nvsim-server/src/main.rs b/v2/crates/nvsim-server/src/main.rs new file mode 100644 index 00000000..abab93c3 --- /dev/null +++ b/v2/crates/nvsim-server/src/main.rs @@ -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>, +} + +#[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, +} + +#[derive(Serialize)] +struct WitnessBody { + witness_hex: String, + samples: usize, + seed_hex: String, +} + +#[derive(Deserialize)] +struct VerifyReq { + expected_hex: String, + samples: Option, +} + +#[derive(Serialize)] +struct VerifyBody { + ok: bool, + actual_hex: String, + expected_hex: String, +} + +#[derive(Deserialize)] +struct StepReq { + direction: Option, + dt_ms: Option, +} + +#[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::() + .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 { + 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) -> Json { + Json(s.inner.lock().await.scene.clone()) +} + +async fn put_scene( + State(s): State, + Json(scene): Json, +) -> Result<&'static str, AppError> { + s.inner.lock().await.scene = scene; + Ok("ok") +} + +async fn get_config(State(s): State) -> Json { + Json(s.inner.lock().await.config) +} + +async fn put_config( + State(s): State, + Json(cfg): Json, +) -> Result<&'static str, AppError> { + s.inner.lock().await.config = cfg; + Ok("ok") +} + +async fn get_seed(State(s): State) -> Json { + let seed = s.inner.lock().await.seed; + Json(SeedBody { + seed_hex: format!("0x{:016X}", seed), + }) +} + +async fn put_seed( + State(s): State, + Json(req): Json, +) -> 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) -> &'static str { + s.inner.lock().await.running = true; + "running" +} + +async fn pause_pipe(State(s): State) -> &'static str { + s.inner.lock().await.running = false; + "paused" +} + +async fn reset_pipe(State(s): State) -> &'static str { + let mut g = s.inner.lock().await; + g.frames_emitted = 0; + g.running = false; + "reset" +} + +async fn step_pipe( + State(s): State, + Json(_req): Json, +) -> Result<&'static str, AppError> { + s.inner.lock().await.frames_emitted += 1; + Ok("ok") +} + +async fn witness_generate( + State(s): State, + Json(req): Json, +) -> Json { + 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, + Json(req): Json, +) -> Result, 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, 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) -> Result, 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, +) -> 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() + } +}