mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
feat(dashboard): WsClient transport + ADR-092/093 status updates
## WsClient — full REST + binary WebSocket transport
New `dashboard/src/transport/WsClient.ts` implementing the same
NvsimClient interface as WasmClient. Talks to `nvsim-server`:
- REST control plane: /api/health, /api/scene, /api/config, /api/seed,
/api/run, /api/pause, /api/reset, /api/step, /api/witness/{generate,verify},
/api/export-proof
- Binary WebSocket data plane: /ws/stream — parses 32-frame MagFrame
batches and forwards to the same onFrames subscribers WasmClient uses
- Transport-flip awareness: connection events emit log lines, fps signal
is computed from incoming batches at 1-second granularity
## main.ts — transport-aware boot
- Restores `transport` + `wsUrl` preferences from IndexedDB at startup
- Watches `transport.value` and `wsUrl.value` signals; on change, tears
down the active client and re-boots into the selected mode
- Auto-reverifies witness whenever a fresh transport boot completes —
prevents drift in Settings drawer transport switching
- onFrames closure extracted so wireClient() can subscribe it on every
re-boot without re-allocating runtime state
## ADR-092 status header + §11 acceptance table
Status changed from Proposed to "Implemented (2026-04-27)". §11
acceptance table now an explicit pass/fail matrix:
8 ✅ — UI fidelity, determinism (WASM), throughput, bundle size,
offline PWA, REPL parity, shortcut parity, witness UI
4 ⚠ — formal axe scan, multi-browser, mode-switch byte-equivalence
across deployed nvsim-server, full keyboard-only flow
The 4 ⚠ items require external infrastructure (axe-core CI, FF/Safari
test runs, deployed nvsim-server) or auditor sign-off; none are
blocked by the dashboard codebase.
## ADR-093 §5 iteration plan
Status changed from Proposed to "Mostly Implemented (2026-04-27)".
Iterations A through I (the originally-planned alphabet) plus three
new iterations J/K/L/M (UX usability pass, Home view, WsClient,
App Store runtime) all closed. 19 of 21 P0/P1/P2 items resolved;
remaining 2 are P2.4 (light-theme contrast color-system pass) and
P2.6 (keyboard arrow-key scene nav).
Validated end-to-end on https://ruvnet.github.io/RuView/nvsim/ —
transport-aware boot logs `transport WASM · nvsim@0.3.0 · magic=0xC51A6E70`
followed by `witness verified · determinism gate ✓ · transport=wasm`.
Switching to WS in Settings would now connect to a user-supplied
nvsim-server; the same auto-reverify fires after the flip.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
+101
-52
@@ -4,8 +4,11 @@ import './components/nv-app';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { WasmClient } from './transport/WasmClient';
|
||||
import { WsClient } from './transport/WsClient';
|
||||
import type { NvsimClient, MagFrameBatch } from './transport/NvsimClient';
|
||||
import {
|
||||
setClient, transport, theme, density, motionReduced,
|
||||
setClient, transport, wsUrl, connected, transportError,
|
||||
theme, density, motionReduced,
|
||||
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
|
||||
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
|
||||
replHistory, scenePositions, type SceneItemPos,
|
||||
@@ -48,48 +51,41 @@ function applyMotion(reduced: boolean): void {
|
||||
if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
|
||||
effect(() => { void kvSet('scene-positions', scenePositions.value); });
|
||||
|
||||
// Boot WASM client
|
||||
const client = new WasmClient();
|
||||
setClient(client);
|
||||
// Restore WS URL preference + transport mode
|
||||
const savedWsUrl = (await kvGet<string>('wsUrl')) ?? '';
|
||||
if (savedWsUrl) wsUrl.value = savedWsUrl;
|
||||
const savedTransport = (await kvGet<'wasm' | 'ws'>('transport')) ?? 'wasm';
|
||||
transport.value = savedTransport;
|
||||
effect(() => { void kvSet('wsUrl', wsUrl.value); });
|
||||
effect(() => { void kvSet('transport', transport.value); });
|
||||
|
||||
pushLog('info', 'nvsim — booting WASM runtime');
|
||||
client.onEvent((ev) => {
|
||||
if (ev.type === 'log') pushLog(ev.level, ev.msg);
|
||||
if (ev.type === 'fps') fps.value = ev.value;
|
||||
if (ev.type === 'state') {
|
||||
framesEmitted.value = BigInt(ev.framesEmitted);
|
||||
}
|
||||
});
|
||||
|
||||
// Per-app runtime scratch state + history buffer.
|
||||
// Per-app runtime scratch state + history buffer (defined first so the
|
||||
// onFrames callback can close over them).
|
||||
const appState: Record<string, Record<string, number>> = {};
|
||||
const bMagHistory: number[] = [];
|
||||
const runtimeStartTs = performance.now();
|
||||
|
||||
client.onFrames((batch) => {
|
||||
const onFrames = (batch: MagFrameBatch): void => {
|
||||
if (batch.frames.length === 0) return;
|
||||
const last = batch.frames[batch.frames.length - 1];
|
||||
lastFrame.value = last;
|
||||
const bx = last.bPt[0] * 1e-12; // pT → T
|
||||
const bx = last.bPt[0] * 1e-12;
|
||||
const by = last.bPt[1] * 1e-12;
|
||||
const bz = last.bPt[2] * 1e-12;
|
||||
lastB.value = [bx, by, bz];
|
||||
const bmagT = Math.sqrt(bx * bx + by * by + bz * bz);
|
||||
bMag.value = bmagT;
|
||||
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
|
||||
const amp = Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3);
|
||||
pushStripBar(amp);
|
||||
|
||||
pushStripBar(Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3));
|
||||
bMagHistory.push(bmagT);
|
||||
while (bMagHistory.length > 256) bMagHistory.shift();
|
||||
|
||||
// Dispatch the frame to every active simulated app runtime.
|
||||
const activeIds = activeAppIds.value;
|
||||
if (activeIds.size === 0) return;
|
||||
const elapsedS = (performance.now() - runtimeStartTs) / 1000;
|
||||
for (const id of activeIds) {
|
||||
const fn = APP_RUNTIMES[id];
|
||||
if (!fn) continue; // mesh-only apps — toggle persists, no in-browser runtime
|
||||
if (!fn) continue;
|
||||
if (!appState[id]) appState[id] = {};
|
||||
const ctx: AppRuntimeContext = {
|
||||
frame: last,
|
||||
@@ -112,40 +108,93 @@ function applyMotion(reduced: boolean): void {
|
||||
pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Boot transport (WASM by default, WS if user previously selected it)
|
||||
let activeClient: NvsimClient | null = null;
|
||||
async function bootTransport(): Promise<void> {
|
||||
try {
|
||||
if (activeClient) await activeClient.close();
|
||||
const want = transport.value;
|
||||
if (want === 'ws' && wsUrl.value.trim()) {
|
||||
const c = new WsClient(wsUrl.value.trim());
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WS · ${wsUrl.value} · nvsim@${info.buildVersion}`);
|
||||
} else {
|
||||
if (want === 'ws') {
|
||||
pushLog('warn', 'WS transport selected but no URL set — falling back to WASM');
|
||||
}
|
||||
const c = new WasmClient();
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WASM · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
|
||||
}
|
||||
setClient(activeClient);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
transportError.value = msg;
|
||||
connected.value = false;
|
||||
pushLog('err', `transport boot failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
function wireClient(c: NvsimClient): void {
|
||||
c.onEvent((ev) => {
|
||||
if (ev.type === 'log') pushLog(ev.level, ev.msg);
|
||||
if (ev.type === 'fps') fps.value = ev.value;
|
||||
if (ev.type === 'state') framesEmitted.value = BigInt(ev.framesEmitted);
|
||||
});
|
||||
c.onFrames(onFrames);
|
||||
}
|
||||
|
||||
// React to transport-mode flips: tear down + re-boot.
|
||||
let bootInProgress = false;
|
||||
effect(() => {
|
||||
transport.value; wsUrl.value;
|
||||
if (bootInProgress) return;
|
||||
bootInProgress = true;
|
||||
void bootTransport().finally(() => { bootInProgress = false; });
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await client.boot();
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
pushLog('ok', `WASM module ready · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
|
||||
pushLog('info', `expected witness · ${info.expectedWitnessHex.slice(0, 16)}…`);
|
||||
pushLog('info', 'nvsim — booting transport');
|
||||
|
||||
// Load reference scene by default.
|
||||
sceneJson.value = '(reference scene)';
|
||||
transport.value = 'wasm';
|
||||
} catch (e) {
|
||||
pushLog('err', `boot failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
// Auto-verify witness once at boot — proves WASM determinism contract.
|
||||
try {
|
||||
// Initial boot — handled by the effect() above.
|
||||
// Auto-verify witness whenever a fresh transport boot completes.
|
||||
let verifiedFor: string | null = null;
|
||||
effect(() => {
|
||||
const exp = expectedWitness.value;
|
||||
if (exp) {
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await client.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness verified · determinism gate ✓`);
|
||||
} else {
|
||||
const actual = Array.from(r.actual)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}…`);
|
||||
const isConn = connected.value;
|
||||
if (!exp || !isConn) return;
|
||||
if (verifiedFor === exp) return;
|
||||
verifiedFor = exp;
|
||||
void (async () => {
|
||||
const c = activeClient;
|
||||
if (!c) return;
|
||||
try {
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness verified · determinism gate ✓ · transport=${transport.value}`);
|
||||
} else {
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
sceneJson.value = '(reference scene)';
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user