fix: comprehensive audit fixes for dashboard and backend

Backend:
- Emit WebSocket events from REST delete/promote/demote handlers
- Emit DreamStarted/ConsolidationStarted from MCP tool dispatch
- Add path validation in backup_to() for defense-in-depth

Dashboard:
- Fix ConnectionDiscovered field names (source_id/target_id)
- Fix $effect → onMount in settings (prevents infinite loop)
- Fix $derived → $derived.by in RetentionCurve
- Fix field name mismatches in settings (nodesProcessed, etc.)
- Fix nested <button> → <span role="button"> in memories
- Fix unhandled Promise rejection in stats consolidation
- Add missing EVENT_TYPE_COLORS entries
- Add Three.js resource disposal and event listener cleanup
- Eliminate duplicate root page, redirect to /graph
- Update nav links and keyboard shortcuts to /graph

All 734+ tests passing, 22MB binary, zero build warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-02-22 15:50:47 -06:00
parent 22831af509
commit ec2af6e71b
220 changed files with 347 additions and 443 deletions

View file

@ -69,6 +69,19 @@
onDestroy(() => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', onResize);
container?.removeEventListener('pointermove', onPointerMove);
container?.removeEventListener('click', onClick);
// Dispose Three.js resources to prevent GPU memory leaks
scene?.traverse((obj: THREE.Object3D) => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.InstancedMesh) {
obj.geometry?.dispose();
if (Array.isArray(obj.material)) {
obj.material.forEach((m: THREE.Material) => m.dispose());
} else if (obj.material) {
(obj.material as THREE.Material).dispose();
}
}
});
renderer?.dispose();
composer?.dispose();
});
@ -554,9 +567,9 @@
break;
}
case 'ConnectionDiscovered': {
const data = event.data as { source?: string; target?: string };
const srcPos = data.source ? nodePositions.get(data.source) : null;
const tgtPos = data.target ? nodePositions.get(data.target) : null;
const data = event.data as { source_id?: string; target_id?: string };
const srcPos = data.source_id ? nodePositions.get(data.source_id) : null;
const tgtPos = data.target_id ? nodePositions.get(data.target_id) : null;
if (srcPos && tgtPos) {
createConnectionFlash(srcPos, tgtPos, new THREE.Color(0xf59e0b));
}

View file

@ -16,7 +16,7 @@
}
// Generate SVG path for the decay curve
let curvePath = $derived(() => {
let curvePath = $derived.by(() => {
const points: string[] = [];
const maxDays = Math.max(stability * 3, 30);
const padding = 4;
@ -56,10 +56,10 @@
<line x1="4" y1="{4 + (height - 8) * 0.8}" x2="{width - 4}" y2="{4 + (height - 8) * 0.8}" stroke="#ef444430" stroke-width="0.5" stroke-dasharray="2,4" />
<!-- Decay curve -->
<path d={curvePath()} fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" />
<path d={curvePath} fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" />
<!-- Fill under curve -->
<path d="{curvePath()} L{width - 4},{height - 4} L4,{height - 4} Z" fill="url(#curveGrad)" opacity="0.15" />
<path d="{curvePath} L{width - 4},{height - 4} L4,{height - 4} Z" fill="url(#curveGrad)" opacity="0.15" />
<!-- Current retention dot -->
<circle cx="4" cy="{4 + (1 - retention) * (height - 8)}" r="3" fill={retColor(retention)} />

View file

@ -196,12 +196,17 @@ export const EVENT_TYPE_COLORS: Record<string, string> = {
MemoryCreated: '#10b981',
MemoryUpdated: '#3b82f6',
MemoryDeleted: '#ef4444',
MemoryPromoted: '#22c55e',
MemoryDemoted: '#f97316',
SearchPerformed: '#6366f1',
DreamStarted: '#8b5cf6',
DreamProgress: '#7c3aed',
DreamCompleted: '#a855f7',
ConsolidationStarted: '#f59e0b',
ConsolidationCompleted: '#f97316',
RetentionDecayed: '#ef4444',
ConnectionDiscovered: '#06b6d4',
ActivationSpread: '#14b8a6',
ImportanceScored: '#ec4899',
Heartbeat: '#6b7280',
};

View file

@ -124,12 +124,15 @@
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
</div>
<div class="flex gap-2">
<button onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded hover:bg-recall/30">Promote</button>
<button onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded hover:bg-decay/30">Demote</button>
<button onclick={(e) => { e.stopPropagation(); api.memories.delete(memory.id); loadMemories(); }}
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded hover:bg-decay/20 ml-auto">Delete</button>
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded hover:bg-recall/30 cursor-pointer select-none">Promote</span>
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }}
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded hover:bg-decay/30 cursor-pointer select-none">Demote</span>
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }}
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }}
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
</div>
</div>
{/if}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$stores/api';
import { isConnected, memoryCount, avgRetention } from '$stores/websocket';
@ -16,7 +17,7 @@
// Health
let health = $state<Record<string, unknown> | null>(null);
$effect(() => {
onMount(() => {
loadAllData();
});
@ -112,21 +113,21 @@
{#if consolidationResult}
<div class="bg-deep/50 p-3 rounded-lg border border-subtle/10">
<div class="grid grid-cols-3 gap-3 text-center">
{#if consolidationResult.processed !== undefined}
{#if consolidationResult.nodesProcessed !== undefined}
<div>
<div class="text-lg text-text font-semibold">{consolidationResult.processed}</div>
<div class="text-lg text-text font-semibold">{consolidationResult.nodesProcessed}</div>
<div class="text-[10px] text-muted">Processed</div>
</div>
{/if}
{#if consolidationResult.decayed !== undefined}
{#if consolidationResult.decayApplied !== undefined}
<div>
<div class="text-lg text-decay font-semibold">{consolidationResult.decayed}</div>
<div class="text-lg text-decay font-semibold">{consolidationResult.decayApplied}</div>
<div class="text-[10px] text-muted">Decayed</div>
</div>
{/if}
{#if consolidationResult.embedded !== undefined}
{#if consolidationResult.embeddingsGenerated !== undefined}
<div>
<div class="text-lg text-synapse-glow font-semibold">{consolidationResult.embedded}</div>
<div class="text-lg text-synapse-glow font-semibold">{consolidationResult.embeddingsGenerated}</div>
<div class="text-[10px] text-muted">Embedded</div>
</div>
{/if}
@ -181,10 +182,10 @@
<span class="text-recall"></span> Retention Distribution
</h2>
<div class="p-4 bg-surface/30 border border-subtle/20 rounded-lg">
{#if retentionDist.buckets && Array.isArray(retentionDist.buckets)}
{#if retentionDist.distribution && Array.isArray(retentionDist.distribution)}
<div class="flex items-end gap-1 h-32">
{#each retentionDist.buckets as bucket, i}
{@const maxCount = Math.max(...(retentionDist.buckets as {count: number}[]).map((b: {count: number}) => b.count), 1)}
{#each retentionDist.distribution as bucket, i}
{@const maxCount = Math.max(...(retentionDist.distribution as {count: number}[]).map((b: {count: number}) => b.count), 1)}
{@const height = ((bucket as {count: number}).count / maxCount) * 100}
{@const color = i < 2 ? '#ef4444' : i < 4 ? '#f59e0b' : i < 7 ? '#6366f1' : '#10b981'}
<div class="flex-1 flex flex-col items-center gap-1">

View file

@ -27,9 +27,12 @@
}
async function runConsolidation() {
try { await api.consolidate(); } catch { /* ignore */ }
// Refresh
[stats, health, retention] = await Promise.all([api.stats(), api.health(), api.retentionDistribution()]);
try {
await api.consolidate();
[stats, health, retention] = await Promise.all([api.stats(), api.health(), api.retentionDistribution()]);
} catch {
// API not available
}
}
</script>

View file

@ -37,7 +37,7 @@
}
// Single-key navigation shortcuts
const shortcutMap: Record<string, string> = {
g: '/', m: '/memories', t: '/timeline', f: '/feed',
g: '/graph', m: '/memories', t: '/timeline', f: '/feed',
e: '/explore', i: '/intentions', s: '/stats',
};
const target = shortcutMap[e.key.toLowerCase()];
@ -55,7 +55,7 @@
});
const nav = [
{ href: '/', label: 'Graph', icon: '◎', shortcut: 'G' },
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
@ -69,9 +69,8 @@
const mobileNav = nav.slice(0, 5);
function isActive(href: string, currentPath: string): boolean {
// Strip base prefix for comparison
const path = currentPath.startsWith(base) ? currentPath.slice(base.length) || '/' : currentPath;
if (href === '/') return path === '/' || path === '/graph';
if (href === '/graph') return path === '/' || path === '/graph';
return path.startsWith(href);
}
@ -94,7 +93,7 @@
<!-- Desktop Sidebar (hidden on mobile) -->
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 bg-abyss border-r border-subtle/30 flex-col">
<!-- Logo -->
<a href="/" class="flex items-center gap-3 px-4 py-5 border-b border-subtle/20">
<a href="/graph" class="flex items-center gap-3 px-4 py-5 border-b border-subtle/20">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright text-sm font-bold">
V
</div>

View file

@ -1,158 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Graph3D from '$components/Graph3D.svelte';
import { api } from '$stores/api';
import { eventFeed } from '$stores/websocket';
import type { GraphResponse, Memory } from '$types';
let graphData: GraphResponse | null = $state(null);
let selectedMemory: Memory | null = $state(null);
let loading = $state(true);
let error = $state('');
let isDreaming = $state(false);
onMount(async () => {
try {
graphData = await api.graph({ max_nodes: 150, depth: 3 });
} catch (e) {
error = 'No memories yet. Start using Vestige to see your memory graph.';
} finally {
loading = false;
}
});
async function triggerDream() {
isDreaming = true;
try {
const result = await api.dream();
// Reload graph with new connections
graphData = await api.graph({ max_nodes: 150, depth: 3 });
} catch {
// Dream failed silently
} finally {
isDreaming = false;
}
}
async function onNodeSelect(nodeId: string) {
try {
selectedMemory = await api.memories.get(nodeId);
} catch {
selectedMemory = null;
}
}
onMount(() => goto('/graph', { replaceState: true }));
</script>
<div class="h-full relative">
<!-- 3D Graph fills the viewport -->
{#if loading}
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-4">
<div class="w-16 h-16 mx-auto rounded-full border-2 border-synapse/30 border-t-synapse animate-spin"></div>
<p class="text-dim text-sm">Loading memory graph...</p>
</div>
</div>
{:else if error}
<div class="h-full flex items-center justify-center">
<div class="text-center space-y-4 max-w-md px-8">
<div class="text-4xl"></div>
<h2 class="text-xl text-bright">Your Mind Awaits</h2>
<p class="text-dim text-sm">{error}</p>
</div>
</div>
{:else if graphData}
<Graph3D
nodes={graphData.nodes}
edges={graphData.edges}
centerId={graphData.center_id}
events={$eventFeed}
{isDreaming}
onSelect={onNodeSelect}
/>
{/if}
<!-- Floating controls -->
<div class="absolute top-4 left-4 flex gap-2 z-10">
<button
onclick={triggerDream}
disabled={isDreaming}
class="px-4 py-2 rounded-lg bg-dream/20 border border-dream/40 text-dream-glow text-sm
hover:bg-dream/30 transition-all disabled:opacity-50 backdrop-blur-sm
{isDreaming ? 'glow-dream animate-pulse-glow' : ''}"
>
{isDreaming ? '◎ Dreaming...' : '◎ Dream'}
</button>
</div>
<!-- Floating stats -->
<div class="absolute top-4 right-4 z-10 text-xs text-dim backdrop-blur-sm bg-abyss/60 rounded-lg px-3 py-2 border border-subtle/20">
{#if graphData}
<div>{graphData.nodeCount} nodes / {graphData.edgeCount} edges</div>
{/if}
</div>
<!-- Selected memory detail panel -->
{#if selectedMemory}
<div class="absolute right-0 top-0 h-full w-96 bg-abyss/95 backdrop-blur-xl border-l border-subtle/30 p-6 overflow-y-auto z-20">
<div class="flex justify-between items-start mb-4">
<h3 class="text-bright text-sm font-semibold">Memory Detail</h3>
<button onclick={() => selectedMemory = null} class="text-dim hover:text-text text-lg">×</button>
</div>
<div class="space-y-4">
<!-- Type badge -->
<div class="flex gap-2">
<span class="px-2 py-0.5 rounded text-xs bg-synapse/20 text-synapse-glow">{selectedMemory.nodeType}</span>
{#each selectedMemory.tags as tag}
<span class="px-2 py-0.5 rounded text-xs bg-surface text-dim">{tag}</span>
{/each}
</div>
<!-- Content -->
<div class="text-sm text-text leading-relaxed whitespace-pre-wrap">{selectedMemory.content}</div>
<!-- Retention bar -->
<div>
<div class="flex justify-between text-xs text-dim mb-1">
<span>Retention</span>
<span>{(selectedMemory.retentionStrength * 100).toFixed(1)}%</span>
</div>
<div class="h-2 bg-surface rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
style="width: {selectedMemory.retentionStrength * 100}%; background: {
selectedMemory.retentionStrength > 0.7 ? '#10b981' :
selectedMemory.retentionStrength > 0.4 ? '#f59e0b' : '#ef4444'
}"
></div>
</div>
</div>
<!-- Metadata -->
<div class="text-xs text-dim space-y-1">
<div>Created: {new Date(selectedMemory.createdAt).toLocaleDateString()}</div>
<div>Reviews: {selectedMemory.reviewCount ?? 0}</div>
{#if selectedMemory.source}
<div>Source: {selectedMemory.source}</div>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
onclick={() => selectedMemory && api.memories.promote(selectedMemory.id)}
class="flex-1 px-3 py-2 rounded bg-recall/20 text-recall text-xs hover:bg-recall/30 transition"
>
Promote
</button>
<button
onclick={() => selectedMemory && api.memories.demote(selectedMemory.id)}
class="flex-1 px-3 py-2 rounded bg-decay/20 text-decay text-xs hover:bg-decay/30 transition"
>
Demote
</button>
</div>
</div>
</div>
{/if}
</div>