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:
parent
39ec05edcb
commit
5846c3d6d2
|
|
@ -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'
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 {
|
|||
<nv-palette></nv-palette>
|
||||
<nv-debug-hud></nv-debug-hud>
|
||||
<nv-settings-drawer></nv-settings-drawer>
|
||||
<nv-onboarding></nv-onboarding>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue