feat: @agor/stores package (3 stores) + 58 BackendAdapter tests

@agor/stores:
- theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted
- Original files replaced with re-exports (zero consumer changes needed)
- pnpm workspace + Vite/tsconfig aliases configured

BackendAdapter tests (58 new):
- backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam)
- tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params)
- electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs)

Total: 523 tests passing (was 465, +58)
This commit is contained in:
Hibryda 2026-03-22 04:45:56 +01:00
parent 5e1fd62ed9
commit f0850f0785
22 changed files with 1389 additions and 25 deletions

View file

@ -0,0 +1,271 @@
<script lang="ts">
import { appRpc } from '../rpc.ts';
import { getHealthAggregates } from '../health-store.svelte.ts';
import { getActiveTools, getToolHistogram } from '../health-store.svelte.ts';
// ── State ────────────────────────────────────────────────────────────
let ptyConnected = $state(false);
let relayConnections = $state(0);
let activeSidecars = $state(0);
let rpcCallCount = $state(0);
let droppedEvents = $state(0);
let lastRefresh = $state(Date.now());
let health = $derived(getHealthAggregates());
let activeTools = $derived(getActiveTools());
let toolHistogram = $derived(getToolHistogram());
// ── Data fetching ────────────────────────────────────────────────────
async function refresh() {
try {
const res = await appRpc.request['diagnostics.stats']({});
ptyConnected = res.ptyConnected;
relayConnections = res.relayConnections;
activeSidecars = res.activeSidecars;
rpcCallCount = res.rpcCallCount;
droppedEvents = res.droppedEvents;
lastRefresh = Date.now();
} catch (err) {
console.error('[Diagnostics] refresh:', err);
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
// ── Init + polling ────────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
refresh();
pollTimer = setInterval(refresh, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="diagnostics">
<h3 class="sh">Transport Diagnostics</h3>
<!-- Connection status -->
<div class="diag-section">
<h4 class="diag-label">Connections</h4>
<div class="diag-grid">
<span class="diag-key">PTY daemon</span>
<span class="diag-val" class:ok={ptyConnected} class:err={!ptyConnected}>
{ptyConnected ? 'Connected' : 'Disconnected'}
</span>
<span class="diag-key">Relay connections</span>
<span class="diag-val">{relayConnections}</span>
<span class="diag-key">Active sidecars</span>
<span class="diag-val">{activeSidecars}</span>
</div>
</div>
<!-- Health aggregates -->
<div class="diag-section">
<h4 class="diag-label">Agent fleet</h4>
<div class="diag-grid">
<span class="diag-key">Running</span>
<span class="diag-val ok">{health.running}</span>
<span class="diag-key">Idle</span>
<span class="diag-val">{health.idle}</span>
<span class="diag-key">Stalled</span>
<span class="diag-val" class:err={health.stalled > 0}>{health.stalled}</span>
<span class="diag-key">Burn rate</span>
<span class="diag-val">${health.totalBurnRatePerHour.toFixed(2)}/hr</span>
</div>
</div>
<!-- Feature 10: Active tools -->
{#if activeTools.length > 0}
<div class="diag-section">
<h4 class="diag-label">Active tools</h4>
<div class="tool-list">
{#each activeTools as tool}
<div class="tool-item">
<span class="tool-name">{tool.toolName}</span>
<span class="tool-elapsed">{formatDuration(Date.now() - tool.startTime)}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Feature 10: Tool duration histogram -->
{#if toolHistogram.length > 0}
<div class="diag-section">
<h4 class="diag-label">Tool duration (avg)</h4>
<div class="histogram">
{#each toolHistogram as entry}
{@const maxMs = Math.max(...toolHistogram.map(e => e.avgMs))}
<div class="histo-row">
<span class="histo-name">{entry.toolName}</span>
<div class="histo-bar-wrap">
<div
class="histo-bar"
style:width="{maxMs > 0 ? (entry.avgMs / maxMs * 100) : 0}%"
></div>
</div>
<span class="histo-val">{formatDuration(entry.avgMs)} ({entry.count}x)</span>
</div>
{/each}
</div>
</div>
{/if}
<div class="diag-footer">
<span class="diag-key">Last refresh</span>
<span class="diag-val">{new Date(lastRefresh).toLocaleTimeString()}</span>
<button class="refresh-btn" onclick={refresh}>Refresh</button>
</div>
</div>
<style>
.diagnostics {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sh {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ctp-overlay0);
}
.diag-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.diag-label {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-subtext0);
}
.diag-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.125rem 0.5rem;
font-size: 0.75rem;
}
.diag-key { color: var(--ctp-subtext0); }
.diag-val {
color: var(--ctp-text);
font-weight: 500;
font-variant-numeric: tabular-nums;
text-align: right;
}
.diag-val.ok { color: var(--ctp-green); }
.diag-val.err { color: var(--ctp-red); }
.tool-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.tool-item {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
}
.tool-name {
color: var(--ctp-blue);
font-family: var(--term-font-family, monospace);
font-weight: 500;
}
.tool-elapsed { color: var(--ctp-overlay1); font-variant-numeric: tabular-nums; }
.histogram {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.histo-row {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.625rem;
}
.histo-name {
width: 5rem;
flex-shrink: 0;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--term-font-family, monospace);
}
.histo-bar-wrap {
flex: 1;
height: 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.125rem;
overflow: hidden;
}
.histo-bar {
height: 100%;
background: var(--ctp-mauve);
border-radius: 0.125rem;
transition: width 0.3s;
}
.histo-val {
width: 5.5rem;
flex-shrink: 0;
text-align: right;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.diag-footer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.625rem;
color: var(--ctp-overlay0);
border-top: 1px solid var(--ctp-surface0);
padding-top: 0.5rem;
}
.refresh-btn {
margin-left: auto;
padding: 0.125rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.625rem;
cursor: pointer;
}
.refresh-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
</style>

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
import { setRetentionConfig } from '../agent-store.svelte.ts';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
type AnchorScale = typeof ANCHOR_SCALES[number];
@ -34,6 +35,10 @@
let selectedId = $state('p1');
let proj = $derived(projects.find(p => p.id === selectedId)!);
// Feature 6: Retention settings
let sessionRetentionCount = $state(5);
let sessionRetentionDays = $state(30);
function updateProj(patch: Partial<ProjectConfig>) {
projects = projects.map(p => p.id === selectedId ? { ...p, ...patch } : p);
const updated = projects.find(p => p.id === selectedId)!;
@ -43,6 +48,17 @@
}).catch(console.error);
}
// Feature 6: Save retention settings
function updateRetention(key: string, value: number) {
if (key === 'count') sessionRetentionCount = value;
else sessionRetentionDays = value;
setRetentionConfig(sessionRetentionCount, sessionRetentionDays);
appRpc?.request['settings.set']({
key: key === 'count' ? 'session_retention_count' : 'session_retention_days',
value: String(value),
}).catch(console.error);
}
onMount(async () => {
if (!appRpc) return;
const res = await appRpc.request['settings.getProjects']({}).catch(() => ({ projects: [] }));
@ -52,6 +68,12 @@
});
if (loaded.length > 0) projects = loaded;
}
// Feature 6: Load retention settings
try {
const { settings } = await appRpc.request['settings.getAll']({});
if (settings['session_retention_count']) sessionRetentionCount = parseInt(settings['session_retention_count'], 10) || 5;
if (settings['session_retention_days']) sessionRetentionDays = parseInt(settings['session_retention_days'], 10) || 30;
} catch { /* use defaults */ }
});
</script>
@ -110,6 +132,23 @@
{/each}
</div>
<!-- Feature 6: Session retention controls -->
<h3 class="sh" style="margin-top: 0.625rem;">Session retention</h3>
<div class="slider-row">
<span class="lbl">Keep last</span>
<input type="range" min="1" max="20" step="1" value={sessionRetentionCount}
oninput={e => updateRetention('count', parseInt((e.target as HTMLInputElement).value, 10))}
/>
<span class="slider-val">{sessionRetentionCount}</span>
</div>
<div class="slider-row">
<span class="lbl">Max age</span>
<input type="range" min="1" max="90" step="1" value={sessionRetentionDays}
oninput={e => updateRetention('days', parseInt((e.target as HTMLInputElement).value, 10))}
/>
<span class="slider-val">{sessionRetentionDays}d</span>
</div>
<h3 class="sh" style="margin-top: 0.625rem;">Custom context</h3>
<textarea
class="prompt"