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

85
.github/workflows/dashboard-pages.yml vendored Normal file
View File

@ -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'

View File

@ -9,12 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@preact/signals-core": "^1.8.0", "@preact/signals-core": "^1.8.0",
"lit": "^3.2.1" "lit": "^3.2.1",
"workbox-window": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4" "vitest": "^2.1.4"
} }
}, },
@ -5861,17 +5862,17 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "0.21.2", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
"integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.6", "debug": "^4.3.6",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10", "tinyglobby": "^0.2.10",
"workbox-build": "^7.3.0", "workbox-build": "^7.4.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.4.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@ -5880,10 +5881,10 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
}, },
"peerDependencies": { "peerDependencies": {
"@vite-pwa/assets-generator": "^0.2.6", "@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"workbox-build": "^7.3.0", "workbox-build": "^7.4.0",
"workbox-window": "^7.3.0" "workbox-window": "^7.4.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vite-pwa/assets-generator": { "@vite-pwa/assets-generator": {
@ -6304,7 +6305,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {
@ -6420,7 +6420,6 @@
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
"integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/trusted-types": "^2.0.2", "@types/trusted-types": "^2.0.2",

View File

@ -14,12 +14,13 @@
}, },
"dependencies": { "dependencies": {
"@preact/signals-core": "^1.8.0", "@preact/signals-core": "^1.8.0",
"lit": "^3.2.1" "lit": "^3.2.1",
"workbox-window": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4" "vitest": "^2.1.4"
} }
} }

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" rx="36" fill="#e6a86b"/>
<text x="96" y="124" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="80" fill="#1a0f00">NV</text>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#e6a86b"/>
<stop offset="1" stop-color="#a4633a"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#g)"/>
<text x="256" y="332" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="220" fill="#1a0f00">NV</text>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@ -16,6 +16,7 @@ import './nv-modal';
import './nv-palette'; import './nv-palette';
import './nv-debug-hud'; import './nv-debug-hud';
import './nv-settings-drawer'; import './nv-settings-drawer';
import './nv-onboarding';
export type View = 'scene' | 'apps' | 'settings'; export type View = 'scene' | 'apps' | 'settings';
@ -87,6 +88,7 @@ export class NvApp extends LitElement {
<nv-palette></nv-palette> <nv-palette></nv-palette>
<nv-debug-hud></nv-debug-hud> <nv-debug-hud></nv-debug-hud>
<nv-settings-drawer></nv-settings-drawer> <nv-settings-drawer></nv-settings-drawer>
<nv-onboarding></nv-onboarding>
`; `;
} }
} }

View File

@ -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: `<p><b>nvsim</b> 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.</p>
<p>This 30-second tour highlights the four panels you'll use most.</p>`,
cta: 'Start tour',
},
{
title: '1. Scene canvas',
body: `<p>The middle panel shows your <b>magnetic scene</b> — 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.</p>
<p>Click <code>2</code> on your keyboard any time to jump to the Frame inspector.</p>`,
},
{
title: '2. Run the pipeline',
body: `<p>Click the <b>▶ Run</b> button (top-right) to start streaming
<code>MagFrame</code> 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.</p>
<p><kbd>Space</kbd> toggles run/pause from anywhere.</p>`,
},
{
title: '3. Witness panel',
body: `<p>The <b>Witness</b> tab is the heart of nvsim's determinism contract.
Click <b>Verify</b> 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.</p>
<p>Same input same hash byte-for-byte across browsers, OSes, transports.
If the hash drifts, your build is non-canonical.</p>`,
},
{
title: '4. App Store',
body: `<p>The grid icon on the left rail opens the <b>App Store</b> — 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.</p>
<p>Toggle any card to mark it active in this session; the WS transport
will push the activation set to a connected ESP32 mesh.</p>`,
},
{
title: 'You are ready',
body: `<p>Press <kbd>⌘K</kbd> (or <kbd>Ctrl K</kbd>) any time for the command
palette, <kbd>?</kbd> for the full shortcuts list, or just start clicking.</p>
<p>Source on GitHub:
<code>github.com/ruvnet/RuView</code> · ADR-089, ADR-092 · MIT/Apache-2.0.</p>`,
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<void> {
super.connectedCallback();
window.addEventListener('nv-show-tour', this.show as EventListener);
const seen = await kvGet<boolean>('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<void> {
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`
<div class="card" role="dialog" aria-modal="true" aria-label="Welcome tour">
<div class="h">
<h2>${s.title}</h2>
<button class="skip" @click=${() => this.dismiss()} aria-label="Skip tour">×</button>
</div>
<div class="body" .innerHTML=${s.body}></div>
<div class="footer">
<div class="dots">
${STEPS.map((_, i) => html`<div class="dot ${i === this.step ? 'active' : ''}"></div>`)}
</div>
${this.step > 0
? html`<button class="ghost" @click=${() => this.prev()}>Back</button>`
: ''}
<button class="primary" @click=${() => this.next()}>
${this.step === STEPS.length - 1 ? (s.cta ?? 'Done') : (s.cta ?? 'Next')}
</button>
</div>
</div>
`;
}
}

View File

@ -1,4 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker. // Dashboard for ADR-092 — Vite + Lit + WASM in a Web Worker.
// Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable // Hosted at /RuView/nvsim/ on GitHub Pages; base path is configurable
@ -11,6 +12,48 @@ export default defineConfig({
worker: { worker: {
format: 'es', 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: { build: {
target: 'es2022', target: 'es2022',
sourcemap: true, sourcemap: true,
@ -27,13 +70,9 @@ export default defineConfig({
port: 5173, port: 5173,
strictPort: true, strictPort: true,
fs: { 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: ['..', '.'], allow: ['..', '.'],
}, },
headers: { 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-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Embedder-Policy': 'require-corp',
}, },

View File

@ -20,6 +20,7 @@ members = [
"crates/wifi-densepose-pointcloud", "crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo", "crates/wifi-densepose-geo",
"crates/nvsim", "crates/nvsim",
"crates/nvsim-server",
] ]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`. # excluded from workspace to avoid breaking `cargo test --workspace`.

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 }

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()
}
}