diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 0e255954..dcf26d16 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -81,11 +81,61 @@ if (DEMO) { } const app = express() -const PORT = 5959 +// MOCK_BACKEND_PORT lets a harness (scripts/mock-rpc-parity.mjs) bind an +// ephemeral port instead of colliding with a running dev preview on 5959. +const PORT = Number(process.env.MOCK_BACKEND_PORT) || 5959 // Dev mode from environment (setup, onboarding, existing, boot, or default) const DEV_MODE = process.env.VITE_DEV_MODE || 'default' +// The demo's mesh peer list, shared by mesh.peers and mesh.contacts-list so +// the two can never disagree about who is on the mesh (the daemon merges +// contacts over the same peer map for exactly this reason). +const DEMO_MESH_PEERS = [ + { + contact_id: 1, + advert_name: 'archy-198', + did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', + pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + rssi: -67, + snr: 9.5, + hops: 0, + }, + { + contact_id: 2, + advert_name: 'satoshi-relay', + did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', + pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5', + rssi: -82, + snr: 4.2, + hops: 1, + }, + // No pubkey_hex — mesh.contacts-list omits this peer, matching the daemon's + // `if let Some(pk) = peer.pubkey_hex` guard. + { + contact_id: 3, + advert_name: 'mountain-node', + did: null, + pubkey_hex: null, + rssi: -95, + snr: 1.8, + hops: 2, + }, + { + contact_id: 4, + advert_name: 'bunker-alpha', + did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb', + pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', + rssi: -74, + snr: 7.1, + hops: 0, + }, +] + +// Ages the peer list's last_heard relative to now, as mesh.peers has always +// reported it (the seed values are offsets in ms). +const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 } + // Boot mode: simulate server startup delay let BOOT_START_TIME = Date.now() const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis) @@ -228,6 +278,15 @@ function seedMockState() { return { analyticsEnabled: false, nodeVisibility: 'discoverable', + // Mesh assistant config — mirrors the daemon's AssistantConfig defaults + // (assistant.rs). Disabled by default, trusted-only, no model override. + assistantConfig: { + enabled: false, + model: null, + trusted_only: true, + backend: 'ollama', + allowed_contacts: [], + }, // Discovery starts OFF, matching the production default — enabling it in // the UI walks through the presence-signing overlay. nostrDiscovery: false, @@ -243,6 +302,21 @@ function seedMockState() { state: 'pending', outbound: false, }, + // An outbound request still awaiting an answer, so the demo can exercise + // federation.cancel-request — the daemon only permits cancelling an + // outbound request in 'sent' state, so without one the demo's cancel + // button could only ever produce an error. + { + id: 'preq-demo-out-1', + from_nostr_pubkey: '3c8f1a5d9e2b7c4a6f0d3b8e1a5c9f2d7b4e6a0c3f8d1b5e9a2c7f4d0b6e3a8c', + from_nostr_npub: 'npub1demo0outbound0request0abcd', + from_did: 'did:key:z6MkqR3nT8yVb2xLc7fPu4dWe6gJa9mHzN1oKiX5vYtA3sqp', + from_name: 'harbor-node', + message: 'Requested peering — waiting on their approval.', + received_at: new Date(Date.now() - 20 * 60000).toISOString(), + state: 'sent', + outbound: true, + }, ], tollgateEnabled: true, tollgatePrice: 21, @@ -3163,53 +3237,13 @@ app.post('/rpc/v1', (req, res) => { } case 'mesh.peers': { - return res.json({ - result: { - peers: [ - { - contact_id: 1, - advert_name: 'archy-198', - did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', - pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - rssi: -67, - snr: 9.5, - last_heard: new Date(Date.now() - 30000).toISOString(), - hops: 0, - }, - { - contact_id: 2, - advert_name: 'satoshi-relay', - did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', - pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5', - rssi: -82, - snr: 4.2, - last_heard: new Date(Date.now() - 120000).toISOString(), - hops: 1, - }, - { - contact_id: 3, - advert_name: 'mountain-node', - did: null, - pubkey_hex: null, - rssi: -95, - snr: 1.8, - last_heard: new Date(Date.now() - 600000).toISOString(), - hops: 2, - }, - { - contact_id: 4, - advert_name: 'bunker-alpha', - did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb', - pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', - rssi: -74, - snr: 7.1, - last_heard: new Date(Date.now() - 45000).toISOString(), - hops: 0, - }, - ], - count: 4, - }, - }) + // Built from the shared DEMO_MESH_PEERS list so mesh.contacts-list + // cannot drift from it; last_heard is aged relative to now. + const peers = DEMO_MESH_PEERS.map((p) => ({ + ...p, + last_heard: new Date(Date.now() - (DEMO_PEER_LAST_HEARD_MS[p.contact_id] || 0)).toISOString(), + })) + return res.json({ result: { peers, count: peers.length } }) } case 'mesh.messages': { @@ -4440,6 +4474,53 @@ app.post('/rpc/v1', (req, res) => { if (r) r.state = 'rejected' return res.json({ result: { rejected: true, id: params?.id || '' } }) } + // Mirrors federation/handlers.rs handle_federation_cancel_request: only + // an OUTBOUND request still in 'sent' state may be cancelled, the row is + // then dropped locally, and `notify` defaults to TRUE (the daemon calls + // cancelling without notifying a footgun — the peer would keep showing + // an unanswerable request). + case 'federation.cancel-request': { + const id = params?.id + if (!id) { + return res.json({ error: { code: -32602, message: 'Missing id' } }) + } + const notify = params?.notify === undefined ? true : !!params.notify + const req = mockState.pendingPeerRequests.find((x) => x.id === id) + if (!req) { + return res.json({ + error: { code: -32603, message: `Pending request not found: ${id}` }, + }) + } + if (!req.outbound || req.state !== 'sent') { + return res.json({ + error: { + code: -32603, + message: `Can only cancel outbound requests in Sent state (outbound=${!!req.outbound}, state=${req.state})`, + }, + }) + } + mockState.pendingPeerRequests = mockState.pendingPeerRequests.filter((x) => x.id !== id) + return res.json({ result: { cancelled: true, id, notified: notify } }) + } + // Mirrors handle_federation_notify_did_change: fans the rotation proof + // out to every known node and reports per-peer results. The demo has no + // real peers to reach, so every known node reports ok over its recorded + // transport — the shape the UI renders, not a fabricated failure. + case 'federation.notify-did-change': { + for (const key of ['old_did', 'new_did', 'proof_signature', 'proof_message']) { + if (!params?.[key]) { + return res.json({ error: { code: -32602, message: `Missing '${key}'` } }) + } + } + const results = sessionFederationNodes().map((n) => ({ + did: n.did, + status: 'ok', + transport: n.transport || 'tor', + })) + return res.json({ + result: { notified: results.length, failed: 0, results }, + }) + } // ── Mesh chat polish + flash flow (v1.7.117/118 demo coverage) ────── @@ -4609,7 +4690,166 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { cancelled: true } }) } + // ── Contacts (peer aliasing) ──────────────────────────────────────── + // Mirrors typed_messages.rs handle_mesh_contacts_list / + // handle_mesh_contacts_save: contact entries are keyed by pubkey_hex and + // merged OVER the peer list, so a peer with no entry still appears with + // null alias/notes. Peers without a pubkey_hex are omitted, exactly as + // the daemon omits them (it only pushes when peer.pubkey_hex is Some). + case 'mesh.contacts-list': { + const contacts = currentStore().mesh.contacts + const out = DEMO_MESH_PEERS.filter((p) => p.pubkey_hex).map((p) => { + const entry = contacts[p.pubkey_hex] || {} + return { + pubkey: p.pubkey_hex, + contact_id: p.contact_id, + name: p.advert_name, + alias: entry.alias ?? null, + notes: entry.notes ?? null, + pinned: entry.pinned ?? false, + blocked: entry.blocked ?? false, + } + }) + return res.json({ result: { contacts: out } }) + } + + // Upsert semantics match the daemon's: a field absent from params leaves + // the stored value alone (it only assigns when the key is present), so + // saving an alias never clears existing notes. + case 'mesh.contacts-save': { + const pubkey = params?.pubkey + if (!pubkey) { + return res.json({ error: { code: -32602, message: 'Missing pubkey' } }) + } + const contacts = currentStore().mesh.contacts + const entry = contacts[pubkey] || { alias: null, notes: null, pinned: false, blocked: false } + if (params.alias !== undefined) entry.alias = params.alias + if (params.notes !== undefined) entry.notes = params.notes + if (params.pinned !== undefined) entry.pinned = !!params.pinned + contacts[pubkey] = entry + return res.json({ + result: { + saved: true, + pubkey, + alias: entry.alias ?? null, + notes: entry.notes ?? null, + pinned: entry.pinned ?? false, + blocked: entry.blocked ?? false, + }, + }) + } + + // Mirrors status.rs handle_mesh_clear_all, which deletes the mesh state + // files and clears in-memory state, returning { status: "cleared" }. + // Here that means dropping this session's sent messages and blobs. + case 'mesh.clear-all': { + const meshStore = currentStore().mesh + meshStore.dynamic.length = 0 + meshStore.blobs = {} + return res.json({ result: { status: 'cleared' } }) + } + + // ── Scheduled messages ────────────────────────────────────────────── + // Mirrors assistant.rs handle_mesh_schedule_message / list / cancel and + // scheduler.rs ScheduledMessage { id, contact_id, channel, body, + // fire_at, attempts }. The daemon requires body + fire_at and one of + // contact_id / channel, and errors otherwise — so does this. + case 'mesh.schedule-message': { + const body = params?.body + const fireAt = params?.fire_at + if (typeof body !== 'string') { + return res.json({ error: { code: -32602, message: 'body is required' } }) + } + if (typeof fireAt !== 'number') { + return res.json({ error: { code: -32602, message: 'fire_at (unix seconds) is required' } }) + } + const contactId = params?.contact_id + const channel = params?.channel + if (contactId === undefined && channel === undefined) { + return res.json({ + error: { code: -32602, message: 'either contact_id or channel is required' }, + }) + } + const meshStore = currentStore().mesh + const entry = { + id: meshStore.nextScheduledId++, + contact_id: contactId ?? null, + channel: channel ?? null, + body, + fire_at: fireAt, + attempts: 0, + } + meshStore.scheduled.push(entry) + return res.json({ result: entry }) + } + + // Sorted by fire time, as the daemon's scheduler.list() returns them. + case 'mesh.list-scheduled': { + const messages = [...currentStore().mesh.scheduled].sort((a, b) => a.fire_at - b.fire_at) + return res.json({ result: { messages } }) + } + + case 'mesh.cancel-scheduled': { + const id = params?.id + if (typeof id !== 'number') { + return res.json({ error: { code: -32602, message: 'id is required' } }) + } + const meshStore = currentStore().mesh + const before = meshStore.scheduled.length + meshStore.scheduled = meshStore.scheduled.filter((m) => m.id !== id) + return res.json({ result: { cancelled: meshStore.scheduled.length < before } }) + } + + // ── Mesh assistant ────────────────────────────────────────────────── + // Mirrors assistant.rs handle_mesh_assistant_status: the demo reports no + // Ollama and no Claude key, which is the honest answer for a browser + // demo with no local model — the UI's "not detected" path is what a + // visitor should see, not a fabricated model list. + case 'mesh.assistant-status': { + const cfg = mockState.assistantConfig + return res.json({ + result: { + enabled: cfg.enabled, + model: cfg.model, + trusted_only: cfg.trusted_only, + backend: cfg.backend, + allowed_contacts: cfg.allowed_contacts, + default_model: 'llama3.2:3b', + ollama_detected: false, + claude_available: false, + models: [], + denied_askers: [], + }, + }) + } + + // Mirrors handle_mesh_assistant_configure's key-presence semantics: + // model present+string sets, present+null clears to default, absent + // leaves; allowed_contacts present+array replaces the allowlist. + case 'mesh.assistant-configure': { + const cfg = mockState.assistantConfig + if (params?.enabled !== undefined) cfg.enabled = !!params.enabled + if (params?.trusted_only !== undefined) cfg.trusted_only = !!params.trusted_only + if (params?.backend !== undefined) cfg.backend = params.backend + if (params && 'model' in params) cfg.model = params.model + if (Array.isArray(params?.allowed_contacts)) cfg.allowed_contacts = params.allowed_contacts + return res.json({ + result: { + enabled: cfg.enabled, + model: cfg.model, + trusted_only: cfg.trusted_only, + backend: cfg.backend, + allowed_contacts: cfg.allowed_contacts, + }, + }) + } + // Chat actions the demo only needs to acknowledge. + // + // mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON + // PURPOSE: the daemon's handlers have no message-store effect either + // (refresh re-polls the radio, reboot-radio power-cycles it), so giving + // them demo-side state would be divergence, not parity. Do not "fix". case 'mesh.send-reaction': case 'mesh.send-reply': case 'mesh.send-read-receipt': @@ -5634,7 +5874,10 @@ function makeSessionStore() { // Mesh chat mutable state: messages sent this session + attachment bytes // (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo // visitor's uploads are never visible to another. - mesh: { dynamic: [], blobs: {} }, + // contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the + // daemon's state.contacts map. scheduled: queued messages awaiting their + // fire_at, mirroring svc.scheduler's list. + mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 }, sockets: new Set(), lastSeen: Date.now(), } diff --git a/neode-ui/package.json b/neode-ui/package.json index 4f820f10..c71c557e 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -8,6 +8,7 @@ "stop": "./stop-dev.sh", "test": "vitest run", "test:watch": "vitest", + "test:mock-parity": "node scripts/mock-rpc-parity.mjs", "dev": "vite", "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"", "dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"", diff --git a/neode-ui/scripts/mock-rpc-parity.mjs b/neode-ui/scripts/mock-rpc-parity.mjs new file mode 100644 index 00000000..2dac8eb9 --- /dev/null +++ b/neode-ui/scripts/mock-rpc-parity.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// Demo/real RPC parity harness (FED-04). +// +// STATIC stage: cross-references every mesh.*/federation.* RPC method the UI +// actually calls against the cases mock-backend.js implements, and reports any +// the demo would answer with "Method not found". +// +// LIVE stage: boots mock-backend.js on an ephemeral port and drives a scripted +// JSON-RPC sequence, asserting the handlers mutate demo state rather than +// returning bare acknowledgements. +// +// Exits non-zero on any failure. There are deliberately no `|| fallback` +// escapes anywhere in here: a failed spawn, a failed fetch or a missing field +// must fail the run, never produce a green run that measured nothing. + +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const UI_ROOT = resolve(HERE, '..') +const MOCK = join(UI_ROOT, 'mock-backend.js') +const SRC = join(UI_ROOT, 'src') + +// Methods the UI calls that the mock is knowingly allowed to be missing. +// Task 2 emptied this: any gap now fails the run. +const KNOWN_GAPS = [] + +let failures = 0 +const fail = (msg) => { + console.error(` ✗ ${msg}`) + failures++ +} +const pass = (msg) => console.log(` ✓ ${msg}`) + +// ── STATIC ────────────────────────────────────────────────────────────────── +// Only `method: 'mesh.x'` / `method: "mesh.x"` counts as a call site. A bare +// string literal is NOT enough: Mesh.vue and Federation.vue use the same +// dotted names as resource-cache keys (`key: 'mesh.self-did'`, +// `key: 'federation.nodes'`), which are not RPC methods at all. Matching those +// would report permanent phantom gaps and make this harness unfailable. +const CALL_RE = /method:\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g +const CASE_RE = /case\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g + +function walk(dir) { + const out = [] + for (const name of readdirSync(dir)) { + const p = join(dir, name) + const st = statSync(p) + if (st.isDirectory()) out.push(...walk(p)) + else if (/\.(ts|js|vue)$/.test(name)) out.push(p) + } + return out +} + +function collect(re, text, into) { + let m + while ((m = re.exec(text)) !== null) into.add(m[1]) +} + +console.log('STATIC: cross-referencing UI call sites against mock handlers') +const called = new Set() +for (const file of walk(SRC)) collect(new RegExp(CALL_RE), readFileSync(file, 'utf8'), called) + +const handled = new Set() +collect(new RegExp(CASE_RE), readFileSync(MOCK, 'utf8'), handled) + +const missing = [...called].filter((m) => !handled.has(m)).sort() +const unexpected = missing.filter((m) => !KNOWN_GAPS.includes(m)) + +console.log(` UI calls ${called.size} mesh/federation methods; mock implements ${handled.size}`) +if (missing.length === 0) { + pass('every UI-called mesh/federation method has a mock handler') +} else { + for (const m of unexpected) fail(`no mock handler for ${m}`) + for (const m of missing.filter((x) => KNOWN_GAPS.includes(x))) { + console.log(` … known gap (allowed): ${m}`) + } +} + +// ── LIVE ──────────────────────────────────────────────────────────────────── +const freePort = () => + new Promise((res, rej) => { + const srv = createServer() + srv.on('error', rej) + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address() + srv.close(() => res(port)) + }) + }) + +const port = await freePort() +console.log(`LIVE: booting mock-backend.js on :${port}`) + +const child = spawn(process.execPath, [MOCK], { + cwd: UI_ROOT, + env: { ...process.env, MOCK_BACKEND_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], +}) +child.stdout.resume() +child.stderr.resume() + +let childExited = false +child.on('exit', () => { + childExited = true +}) + +const base = `http://127.0.0.1:${port}` +const rpc = async (method, params) => { + const resp = await fetch(`${base}/rpc/v1`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`) + const body = await resp.json() + if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`) + return body.result +} + +try { + // Poll until the server answers (bounded). + const deadline = Date.now() + 10000 + for (;;) { + if (childExited) throw new Error('mock-backend exited before becoming ready') + try { + await rpc('server.echo', { message: 'parity-probe' }) + break + } catch { + if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 10s') + await new Promise((r) => setTimeout(r, 200)) + } + } + pass('mock-backend answered on the ephemeral port') + + // 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks). + const peers = await rpc('mesh.contacts-list') + const target = peers.contacts.find((c) => c.pubkey) + if (!target) throw new Error('mesh.contacts-list returned no contact with a pubkey') + if (target.alias !== null) fail(`expected a fresh contact to have a null alias, got ${target.alias}`) + + await rpc('mesh.contacts-save', { pubkey: target.pubkey, alias: 'Base Camp' }) + const after = await rpc('mesh.contacts-list') + const renamed = after.contacts.find((c) => c.pubkey === target.pubkey) + if (renamed?.alias === 'Base Camp') pass('mesh.contacts-save → contacts-list round-trips the alias') + else fail(`alias did not persist: expected 'Base Camp', got ${JSON.stringify(renamed?.alias)}`) + + // The daemon omits peers with no pubkey_hex; so must the demo. + if (after.contacts.every((c) => c.pubkey)) pass('contacts-list omits peers without a pubkey, as the daemon does') + else fail('contacts-list returned a contact with no pubkey') + + // 2. Scheduling: schedule → list → cancel → list. + const fireAt = Math.floor(Date.now() / 1000) + 3600 + const scheduled = await rpc('mesh.schedule-message', { + body: 'mesh parity check', + fire_at: fireAt, + contact_id: 1, + }) + if (typeof scheduled.id !== 'number') fail('mesh.schedule-message did not return a numeric id') + + const listed = await rpc('mesh.list-scheduled') + if (listed.messages.some((m) => m.id === scheduled.id)) pass('mesh.schedule-message → list-scheduled shows the entry') + else fail('scheduled message missing from mesh.list-scheduled') + + const cancelled = await rpc('mesh.cancel-scheduled', { id: scheduled.id }) + if (cancelled.cancelled !== true) fail('mesh.cancel-scheduled did not report a cancellation') + const afterCancel = await rpc('mesh.list-scheduled') + if (!afterCancel.messages.some((m) => m.id === scheduled.id)) pass('mesh.cancel-scheduled removed the entry') + else fail('cancelled message still present in mesh.list-scheduled') + + // Cancelling an unknown id must report false, not throw or lie. + const ghost = await rpc('mesh.cancel-scheduled', { id: 999999 }) + if (ghost.cancelled === false) pass('cancel-scheduled reports false for an unknown id') + else fail(`cancel-scheduled lied about an unknown id: ${JSON.stringify(ghost)}`) + + // 3. clear-all empties this session's sent messages. + await rpc('mesh.send-content-inline', { + contact_id: 1, + mime: 'text/plain', + filename: 'parity.txt', + bytes_b64: Buffer.from('parity').toString('base64'), + }) + const withMsg = await rpc('mesh.messages', { limit: 500 }) + const cleared = await rpc('mesh.clear-all') + if (cleared.status !== 'cleared') fail(`mesh.clear-all returned ${JSON.stringify(cleared)}`) + const afterClear = await rpc('mesh.messages', { limit: 500 }) + if (afterClear.count < withMsg.count) pass('mesh.clear-all dropped this session\'s messages') + else fail(`mesh.clear-all did not reduce the message count (${withMsg.count} → ${afterClear.count})`) + + // 4. Assistant config round-trip. + const status = await rpc('mesh.assistant-status') + for (const k of ['enabled', 'model', 'trusted_only', 'backend', 'allowed_contacts', 'default_model', 'ollama_detected', 'claude_available', 'models', 'denied_askers']) { + if (!(k in status)) fail(`mesh.assistant-status missing field '${k}'`) + } + const configured = await rpc('mesh.assistant-configure', { enabled: true, model: 'llama3.2:3b' }) + if (configured.enabled === true && configured.model === 'llama3.2:3b') pass('mesh.assistant-configure applies and echoes settings') + else fail(`assistant-configure did not apply: ${JSON.stringify(configured)}`) + const restatus = await rpc('mesh.assistant-status') + if (restatus.enabled === true) pass('assistant config persists across calls') + else fail('assistant config did not persist') + + // 5. federation.cancel-request removes the outbound request. + const pending = await rpc('federation.list-pending-requests') + const outbound = pending.requests.find((r) => r.outbound && r.state === 'sent') + if (!outbound) { + fail('no outbound/sent request in the demo seed — cancel-request is unexercisable') + } else { + const res = await rpc('federation.cancel-request', { id: outbound.id }) + if (res.cancelled !== true || res.notified !== true) fail(`cancel-request returned ${JSON.stringify(res)}`) + const post = await rpc('federation.list-pending-requests') + if (!post.requests.some((r) => r.id === outbound.id)) pass('federation.cancel-request removed the request') + else fail('cancelled request still listed as pending') + } + + // 6. federation.notify-did-change reports per-peer results. + const notified = await rpc('federation.notify-did-change', { + old_did: 'did:key:zOld', + new_did: 'did:key:zNew', + proof_signature: 'demo-sig', + proof_message: 'demo-msg', + }) + if (Array.isArray(notified.results) && notified.notified === notified.results.length) { + pass('federation.notify-did-change reports one result per node') + } else { + fail(`notify-did-change shape wrong: ${JSON.stringify(notified)}`) + } +} catch (err) { + fail(err.message) +} finally { + child.kill('SIGTERM') +} + +if (failures > 0) { + console.error(`\nFAILED: ${failures} parity problem(s)`) + process.exit(1) +} +console.log('\nOK: demo/real mesh + federation RPC parity holds')