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:
parent
5e1fd62ed9
commit
f0850f0785
22 changed files with 1389 additions and 25 deletions
91
src/lib/backend/__tests__/backend-adapter.test.ts
Normal file
91
src/lib/backend/__tests__/backend-adapter.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Tests for getBackend/setBackend/setBackendForTesting lifecycle
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getBackend,
|
||||
setBackend,
|
||||
setBackendForTesting,
|
||||
resetBackendForTesting,
|
||||
} from '../backend';
|
||||
import type { BackendAdapter } from '@agor/types';
|
||||
|
||||
beforeEach(() => {
|
||||
resetBackendForTesting();
|
||||
});
|
||||
|
||||
describe('backend singleton', () => {
|
||||
describe('getBackend', () => {
|
||||
it('throws before setBackend is called', () => {
|
||||
expect(() => getBackend()).toThrow(
|
||||
'[backend] Adapter not initialized. Call setBackend() before accessing the backend.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns adapter after setBackend', () => {
|
||||
const mockAdapter = { capabilities: {} } as unknown as BackendAdapter;
|
||||
setBackend(mockAdapter);
|
||||
expect(getBackend()).toBe(mockAdapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBackend', () => {
|
||||
it('sets the adapter on first call', () => {
|
||||
const mockAdapter = { capabilities: {} } as unknown as BackendAdapter;
|
||||
setBackend(mockAdapter);
|
||||
expect(getBackend()).toBe(mockAdapter);
|
||||
});
|
||||
|
||||
it('throws on second call', () => {
|
||||
const adapter1 = { capabilities: {} } as unknown as BackendAdapter;
|
||||
const adapter2 = { capabilities: {} } as unknown as BackendAdapter;
|
||||
setBackend(adapter1);
|
||||
expect(() => setBackend(adapter2)).toThrow(
|
||||
'[backend] Adapter already set. setBackend() must be called exactly once.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBackendForTesting', () => {
|
||||
it('works before setBackend', () => {
|
||||
const partial = { getSetting: async () => 'test' };
|
||||
setBackendForTesting(partial);
|
||||
expect(getBackend().getSetting).toBeDefined();
|
||||
});
|
||||
|
||||
it('allows overwrite (no single-set enforcement)', () => {
|
||||
const partial1 = { getSetting: async () => 'v1' };
|
||||
const partial2 = { getSetting: async () => 'v2' };
|
||||
setBackendForTesting(partial1);
|
||||
setBackendForTesting(partial2);
|
||||
// Should use second one
|
||||
expect(getBackend()).toBe(partial2 as unknown as BackendAdapter);
|
||||
});
|
||||
|
||||
it('allows overwrite even after setBackend', () => {
|
||||
const adapter = { capabilities: {} } as unknown as BackendAdapter;
|
||||
setBackend(adapter);
|
||||
const partial = { getSetting: async () => 'test' };
|
||||
// setBackendForTesting bypasses single-set check
|
||||
setBackendForTesting(partial);
|
||||
expect(getBackend()).toBe(partial as unknown as BackendAdapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetBackendForTesting', () => {
|
||||
it('resets to uninitialized state', () => {
|
||||
const adapter = { capabilities: {} } as unknown as BackendAdapter;
|
||||
setBackend(adapter);
|
||||
resetBackendForTesting();
|
||||
expect(() => getBackend()).toThrow();
|
||||
});
|
||||
|
||||
it('allows setBackend after reset', () => {
|
||||
const adapter1 = { capabilities: {} } as unknown as BackendAdapter;
|
||||
const adapter2 = { capabilities: { supportsPtyMultiplexing: true } } as unknown as BackendAdapter;
|
||||
setBackend(adapter1);
|
||||
resetBackendForTesting();
|
||||
setBackend(adapter2);
|
||||
expect(getBackend()).toBe(adapter2);
|
||||
});
|
||||
});
|
||||
});
|
||||
246
src/lib/backend/__tests__/electrobun-adapter.test.ts
Normal file
246
src/lib/backend/__tests__/electrobun-adapter.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// Tests for ElectrobunAdapter — verifies RPC call names match pty-rpc-schema.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ElectrobunAdapter } from '../ElectrobunAdapter';
|
||||
import type { BackendAdapter } from '@agor/types';
|
||||
|
||||
// Mock RPC handle matching the interface in ElectrobunAdapter
|
||||
function createMockRpc() {
|
||||
const request: Record<string, ReturnType<typeof vi.fn>> = {};
|
||||
const listeners: Array<{ event: string; handler: (payload: unknown) => void }> = [];
|
||||
|
||||
return {
|
||||
request: new Proxy(request, {
|
||||
get: (_target, prop: string) => {
|
||||
if (!request[prop]) request[prop] = vi.fn().mockResolvedValue({ ok: true });
|
||||
return request[prop];
|
||||
},
|
||||
}),
|
||||
addMessageListener: vi.fn((event: string, handler: (payload: unknown) => void) => {
|
||||
listeners.push({ event, handler });
|
||||
}),
|
||||
removeMessageListener: vi.fn(),
|
||||
_listeners: listeners,
|
||||
_calls: request,
|
||||
};
|
||||
}
|
||||
|
||||
let adapter: ElectrobunAdapter;
|
||||
let mockRpc: ReturnType<typeof createMockRpc>;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new ElectrobunAdapter();
|
||||
mockRpc = createMockRpc();
|
||||
adapter.setRpc(mockRpc as any);
|
||||
});
|
||||
|
||||
describe('ElectrobunAdapter', () => {
|
||||
// ── Interface compliance ──────────────────────────────────────────────
|
||||
|
||||
it('implements BackendAdapter interface', () => {
|
||||
const ba: BackendAdapter = adapter;
|
||||
expect(ba.getSetting).toBeTypeOf('function');
|
||||
expect(ba.setSetting).toBeTypeOf('function');
|
||||
expect(ba.startAgent).toBeTypeOf('function');
|
||||
expect(ba.stopAgent).toBeTypeOf('function');
|
||||
expect(ba.createPty).toBeTypeOf('function');
|
||||
expect(ba.btmsgSend).toBeTypeOf('function');
|
||||
expect(ba.bttaskList).toBeTypeOf('function');
|
||||
expect(ba.memoraAvailable).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('has correct capabilities (no OS features)', () => {
|
||||
expect(adapter.capabilities.supportsPtyMultiplexing).toBe(true);
|
||||
expect(adapter.capabilities.supportsNativeMenus).toBe(false);
|
||||
expect(adapter.capabilities.supportsOsKeychain).toBe(false);
|
||||
expect(adapter.capabilities.supportsDesktopNotifications).toBe(false);
|
||||
expect(adapter.capabilities.supportsTelemetry).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when RPC not initialized', () => {
|
||||
const fresh = new ElectrobunAdapter();
|
||||
expect(() => fresh.getSetting('x')).rejects.toThrow('RPC not initialized');
|
||||
});
|
||||
|
||||
// ── Settings (matches pty-rpc-schema "settings.*") ────────────────────
|
||||
|
||||
describe('settings', () => {
|
||||
it('getSetting calls settings.get', async () => {
|
||||
mockRpc.request['settings.get'].mockResolvedValue({ value: 'mocha' });
|
||||
const result = await adapter.getSetting('theme');
|
||||
expect(mockRpc.request['settings.get']).toHaveBeenCalledWith({ key: 'theme' });
|
||||
expect(result).toBe('mocha');
|
||||
});
|
||||
|
||||
it('setSetting calls settings.set', async () => {
|
||||
await adapter.setSetting('theme', 'mocha');
|
||||
expect(mockRpc.request['settings.set']).toHaveBeenCalledWith({ key: 'theme', value: 'mocha' });
|
||||
});
|
||||
|
||||
it('getAllSettings calls settings.getAll', async () => {
|
||||
mockRpc.request['settings.getAll'].mockResolvedValue({
|
||||
settings: { theme: 'mocha', shell: '/bin/bash' },
|
||||
});
|
||||
const result = await adapter.getAllSettings();
|
||||
expect(result).toEqual({ theme: 'mocha', shell: '/bin/bash' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── PTY (matches pty-rpc-schema "pty.*") ──────────────────────────────
|
||||
|
||||
describe('pty', () => {
|
||||
it('createPty calls pty.create', async () => {
|
||||
mockRpc.request['pty.create'].mockResolvedValue({ ok: true });
|
||||
const id = await adapter.createPty({ sessionId: 's1', cols: 80, rows: 24 });
|
||||
expect(mockRpc.request['pty.create']).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 's1', cols: 80, rows: 24 }),
|
||||
);
|
||||
expect(id).toBe('s1');
|
||||
});
|
||||
|
||||
it('writePty calls pty.write', async () => {
|
||||
await adapter.writePty('s1', 'hello');
|
||||
expect(mockRpc.request['pty.write']).toHaveBeenCalledWith({ sessionId: 's1', data: 'hello' });
|
||||
});
|
||||
|
||||
it('resizePty calls pty.resize', async () => {
|
||||
await adapter.resizePty('s1', 120, 40);
|
||||
expect(mockRpc.request['pty.resize']).toHaveBeenCalledWith({ sessionId: 's1', cols: 120, rows: 40 });
|
||||
});
|
||||
|
||||
it('closePty calls pty.close', async () => {
|
||||
await adapter.closePty('s1');
|
||||
expect(mockRpc.request['pty.close']).toHaveBeenCalledWith({ sessionId: 's1' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent (matches pty-rpc-schema "agent.*") ──────────────────────────
|
||||
|
||||
describe('agent', () => {
|
||||
it('startAgent calls agent.start', async () => {
|
||||
const result = await adapter.startAgent({
|
||||
sessionId: 's1',
|
||||
prompt: 'hello',
|
||||
provider: 'claude',
|
||||
cwd: '/tmp',
|
||||
});
|
||||
expect(mockRpc.request['agent.start']).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 's1', prompt: 'hello', provider: 'claude' }),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('stopAgent calls agent.stop', async () => {
|
||||
const result = await adapter.stopAgent('s1');
|
||||
expect(mockRpc.request['agent.stop']).toHaveBeenCalledWith({ sessionId: 's1' });
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('sendPrompt calls agent.prompt', async () => {
|
||||
const result = await adapter.sendPrompt('s1', 'do something');
|
||||
expect(mockRpc.request['agent.prompt']).toHaveBeenCalledWith({
|
||||
sessionId: 's1',
|
||||
prompt: 'do something',
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Files (matches pty-rpc-schema "files.*") ──────────────────────────
|
||||
|
||||
describe('files', () => {
|
||||
it('listDirectory calls files.list', async () => {
|
||||
mockRpc.request['files.list'].mockResolvedValue({
|
||||
entries: [{ name: 'src', type: 'dir', size: 0 }],
|
||||
});
|
||||
const result = await adapter.listDirectory('/project');
|
||||
expect(mockRpc.request['files.list']).toHaveBeenCalledWith({ path: '/project' });
|
||||
expect(result).toEqual([{ name: 'src', path: '/project/src', isDir: true, size: 0 }]);
|
||||
});
|
||||
|
||||
it('readFile calls files.read', async () => {
|
||||
mockRpc.request['files.read'].mockResolvedValue({
|
||||
content: 'hello', encoding: 'utf8', size: 5,
|
||||
});
|
||||
const result = await adapter.readFile('/tmp/file.ts');
|
||||
expect(result).toEqual({ type: 'Text', content: 'hello' });
|
||||
});
|
||||
|
||||
it('writeFile calls files.write', async () => {
|
||||
await adapter.writeFile('/tmp/file.ts', 'content');
|
||||
expect(mockRpc.request['files.write']).toHaveBeenCalledWith({
|
||||
path: '/tmp/file.ts',
|
||||
content: 'content',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────────
|
||||
|
||||
describe('events', () => {
|
||||
it('onSidecarMessage subscribes to agent.sidecar', () => {
|
||||
const callback = vi.fn();
|
||||
adapter.onSidecarMessage(callback);
|
||||
expect(mockRpc.addMessageListener).toHaveBeenCalledWith('agent.sidecar', expect.any(Function));
|
||||
});
|
||||
|
||||
it('onPtyData filters by session ID', () => {
|
||||
const callback = vi.fn();
|
||||
adapter.onPtyData('pty-1', callback);
|
||||
expect(mockRpc.addMessageListener).toHaveBeenCalledWith('pty.output', expect.any(Function));
|
||||
|
||||
// Simulate event
|
||||
const handler = mockRpc.addMessageListener.mock.calls[0][1];
|
||||
handler({ sessionId: 'pty-1', data: 'hello' });
|
||||
expect(callback).toHaveBeenCalledWith('hello');
|
||||
|
||||
// Different session — should not trigger
|
||||
callback.mockClear();
|
||||
handler({ sessionId: 'pty-2', data: 'world' });
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unsubscribe removes listener', () => {
|
||||
const unsub = adapter.onSidecarMessage(vi.fn());
|
||||
unsub();
|
||||
expect(mockRpc.removeMessageListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stub methods return safe defaults ─────────────────────────────────
|
||||
|
||||
describe('stub methods', () => {
|
||||
it('btmsg stubs return empty arrays/strings', async () => {
|
||||
expect(await adapter.btmsgGetAgents('g1' as any)).toEqual([]);
|
||||
expect(await adapter.btmsgSend('a1' as any, 'a2' as any, 'hi')).toBe('');
|
||||
expect(await adapter.btmsgGetChannels('g1' as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('bttask stubs return empty/zero', async () => {
|
||||
expect(await adapter.bttaskList('g1' as any)).toEqual([]);
|
||||
expect(await adapter.bttaskReviewQueueCount('g1' as any)).toBe(0);
|
||||
});
|
||||
|
||||
it('memora stubs return empty/false', async () => {
|
||||
expect(await adapter.memoraAvailable()).toBe(false);
|
||||
expect(await adapter.memoraList()).toEqual({ nodes: [], total: 0 });
|
||||
});
|
||||
|
||||
it('secrets stubs return null/empty', async () => {
|
||||
expect(await adapter.getSecret('x')).toBeNull();
|
||||
expect(await adapter.hasKeyring()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('destroy', () => {
|
||||
it('removes all registered listeners', async () => {
|
||||
adapter.onSidecarMessage(vi.fn());
|
||||
adapter.onPtyData('p1', vi.fn());
|
||||
await adapter.destroy();
|
||||
// removeMessageListener should have been called for each listener
|
||||
expect(mockRpc.removeMessageListener.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
src/lib/backend/__tests__/tauri-adapter.test.ts
Normal file
268
src/lib/backend/__tests__/tauri-adapter.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// Tests for TauriAdapter — verifies correct Tauri command names + param mapping
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockInvoke, mockListen } = vi.hoisted(() => ({
|
||||
mockInvoke: vi.fn(),
|
||||
mockListen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: mockInvoke }));
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen: mockListen }));
|
||||
|
||||
import { TauriAdapter } from '../TauriAdapter';
|
||||
import type { BackendAdapter } from '@agor/types';
|
||||
|
||||
let adapter: TauriAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
adapter = new TauriAdapter();
|
||||
});
|
||||
|
||||
describe('TauriAdapter', () => {
|
||||
it('implements BackendAdapter interface', () => {
|
||||
const ba: BackendAdapter = adapter;
|
||||
expect(ba.getSetting).toBeTypeOf('function');
|
||||
expect(ba.startAgent).toBeTypeOf('function');
|
||||
expect(ba.createPty).toBeTypeOf('function');
|
||||
expect(ba.btmsgSend).toBeTypeOf('function');
|
||||
expect(ba.bttaskList).toBeTypeOf('function');
|
||||
expect(ba.memoraAvailable).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('has correct capabilities', () => {
|
||||
expect(adapter.capabilities.supportsPtyMultiplexing).toBe(true);
|
||||
expect(adapter.capabilities.supportsOsKeychain).toBe(true);
|
||||
expect(adapter.capabilities.supportsDesktopNotifications).toBe(true);
|
||||
expect(adapter.capabilities.supportsTelemetry).toBe(true);
|
||||
});
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('settings', () => {
|
||||
it('getSetting invokes settings_get', async () => {
|
||||
mockInvoke.mockResolvedValue('dark');
|
||||
expect(await adapter.getSetting('theme')).toBe('dark');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('settings_get', { key: 'theme' });
|
||||
});
|
||||
|
||||
it('setSetting invokes settings_set', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.setSetting('theme', 'mocha');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('settings_set', { key: 'theme', value: 'mocha' });
|
||||
});
|
||||
|
||||
it('getAllSettings invokes settings_list and returns map', async () => {
|
||||
mockInvoke.mockResolvedValue([['theme', 'mocha'], ['shell', '/bin/bash']]);
|
||||
expect(await adapter.getAllSettings()).toEqual({ theme: 'mocha', shell: '/bin/bash' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('agent', () => {
|
||||
it('startAgent invokes agent_query with mapped options', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
const result = await adapter.startAgent({ sessionId: 's1', prompt: 'Hello', cwd: '/tmp', provider: 'claude', maxTurns: 10 });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_query', {
|
||||
options: expect.objectContaining({ session_id: 's1', prompt: 'Hello', provider: 'claude', max_turns: 10 }),
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('startAgent returns error on failure', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('Sidecar not running'));
|
||||
const result = await adapter.startAgent({ sessionId: 's2', prompt: 'test' });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('Sidecar not running');
|
||||
});
|
||||
|
||||
it('stopAgent invokes agent_stop', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
expect(await adapter.stopAgent('s1')).toEqual({ ok: true });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 's1' });
|
||||
});
|
||||
|
||||
it('isAgentReady invokes agent_ready', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
expect(await adapter.isAgentReady()).toBe(true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_ready');
|
||||
});
|
||||
|
||||
it('queryAgent routes local vs remote', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.queryAgent({ session_id: 's1', prompt: 'hi' });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options: { session_id: 's1', prompt: 'hi' } });
|
||||
|
||||
mockInvoke.mockClear();
|
||||
await adapter.queryAgent({ session_id: 's1', prompt: 'hi', remote_machine_id: 'm1' });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('remote_agent_query', {
|
||||
machineId: 'm1', options: { session_id: 's1', prompt: 'hi' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── PTY ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pty', () => {
|
||||
it('createPty invokes pty_spawn', async () => {
|
||||
mockInvoke.mockResolvedValue('pty-1');
|
||||
expect(await adapter.createPty({ sessionId: 's1', cols: 80, rows: 24 })).toBe('pty-1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('pty_spawn', { options: expect.objectContaining({ cols: 80, rows: 24 }) });
|
||||
});
|
||||
|
||||
it('writePty/resizePty/closePty invoke correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.writePty('p1', 'hi');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('pty_write', { id: 'p1', data: 'hi' });
|
||||
|
||||
await adapter.resizePty('p1', 120, 40);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('pty_resize', { id: 'p1', cols: 120, rows: 40 });
|
||||
|
||||
await adapter.closePty('p1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('pty_kill', { id: 'p1' });
|
||||
});
|
||||
|
||||
it('spawnPty uses remote_pty_spawn for remote', async () => {
|
||||
mockInvoke.mockResolvedValue('rpty-1');
|
||||
await adapter.spawnPty({ remote_machine_id: 'm1', cols: 80, rows: 24 });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('remote_pty_spawn', { machineId: 'm1', options: { cols: 80, rows: 24 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('files', () => {
|
||||
it('listDirectory invokes list_directory_children and maps fields', async () => {
|
||||
mockInvoke.mockResolvedValue([{ name: 'f.ts', path: '/tmp/f.ts', is_dir: false, size: 100, ext: 'ts' }]);
|
||||
const result = await adapter.listDirectory('/tmp');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('list_directory_children', { path: '/tmp' });
|
||||
expect(result).toEqual([{ name: 'f.ts', path: '/tmp/f.ts', isDir: false, size: 100, ext: 'ts' }]);
|
||||
});
|
||||
|
||||
it('readFile/writeFile invoke correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue({ type: 'Text', content: 'hello' });
|
||||
expect(await adapter.readFile('/tmp/f.ts')).toEqual({ type: 'Text', content: 'hello' });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('read_file_content', { path: '/tmp/f.ts' });
|
||||
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.writeFile('/tmp/f.ts', 'x');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('write_file_content', { path: '/tmp/f.ts', content: 'x' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Btmsg ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('btmsg', () => {
|
||||
it('btmsgGetAgents invokes btmsg_get_agents', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await adapter.btmsgGetAgents('g1' as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
|
||||
});
|
||||
|
||||
it('btmsgSend invokes btmsg_send with camelCase params', async () => {
|
||||
mockInvoke.mockResolvedValue('msg-1');
|
||||
expect(await adapter.btmsgSend('a1' as any, 'a2' as any, 'hello')).toBe('msg-1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
|
||||
});
|
||||
|
||||
it('btmsgHistory/btmsgMarkRead/btmsgCreateChannel/btmsgRecordHeartbeat use correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await adapter.btmsgHistory('a1' as any, 'a2' as any, 50);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
|
||||
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.btmsgMarkRead('r' as any, 's' as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'r', senderId: 's' });
|
||||
|
||||
mockInvoke.mockResolvedValue('ch-1');
|
||||
expect(await adapter.btmsgCreateChannel('gen', 'g1' as any, 'admin' as any)).toBe('ch-1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'gen', groupId: 'g1', createdBy: 'admin' });
|
||||
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.btmsgRecordHeartbeat('a1' as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('btmsg_record_heartbeat', { agentId: 'a1' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bttask ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bttask', () => {
|
||||
it('bttaskList/bttaskComments invoke correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
await adapter.bttaskList('g1' as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
|
||||
|
||||
await adapter.bttaskComments('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' });
|
||||
});
|
||||
|
||||
it('bttaskCreate invokes bttask_create with all params', async () => {
|
||||
mockInvoke.mockResolvedValue('t1');
|
||||
expect(await adapter.bttaskCreate('Fix', 'Desc', 'high', 'g1' as any, 'admin' as any, 'dev' as any)).toBe('t1');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
|
||||
title: 'Fix', description: 'Desc', priority: 'high', groupId: 'g1', createdBy: 'admin', assignedTo: 'dev',
|
||||
});
|
||||
});
|
||||
|
||||
it('bttaskUpdateStatus uses optimistic locking', async () => {
|
||||
mockInvoke.mockResolvedValue(2);
|
||||
expect(await adapter.bttaskUpdateStatus('t1', 'review', 1)).toBe(2);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'review', version: 1 });
|
||||
});
|
||||
|
||||
it('bttaskReviewQueueCount invokes bttask_review_queue_count', async () => {
|
||||
mockInvoke.mockResolvedValue(3);
|
||||
expect(await adapter.bttaskReviewQueueCount('g1' as any)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memora ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('memora', () => {
|
||||
it('memoraAvailable/memoraList/memoraSearch/memoraGet invoke correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
expect(await adapter.memoraAvailable()).toBe(true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_available');
|
||||
|
||||
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
|
||||
await adapter.memoraList();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_list', { tags: null, limit: 50, offset: 0 });
|
||||
|
||||
await adapter.memoraSearch('arch', { tags: ['agor'], limit: 10 });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_search', { query: 'arch', tags: ['agor'], limit: 10 });
|
||||
|
||||
mockInvoke.mockResolvedValue({ id: 5, content: 'test', tags: [] });
|
||||
expect(await adapter.memoraGet(5)).toEqual({ id: 5, content: 'test', tags: [] });
|
||||
expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Secrets + Groups ──────────────────────────────────────────────────
|
||||
|
||||
describe('secrets', () => {
|
||||
it('storeSecret/getSecret invoke correct commands', async () => {
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.storeSecret('key', 'val');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('secrets_store', { key: 'key', value: 'val' });
|
||||
|
||||
mockInvoke.mockResolvedValue('val');
|
||||
expect(await adapter.getSecret('key')).toBe('val');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('secrets_get', { key: 'key' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('groups', () => {
|
||||
it('loadGroups/saveGroups invoke correct commands', async () => {
|
||||
const groups = { version: 1, groups: [], activeGroupId: 'default' };
|
||||
mockInvoke.mockResolvedValue(groups);
|
||||
expect(await adapter.loadGroups()).toEqual(groups);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('groups_load');
|
||||
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
await adapter.saveGroups(groups as any);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('groups_save', { config: groups });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -18,4 +18,4 @@ export {
|
|||
getAllProjectHealth,
|
||||
getAttentionQueue,
|
||||
getHealthAggregates,
|
||||
} from '@agor/stores/health.svelte';
|
||||
} from '@agor/stores';
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ export {
|
|||
markRead,
|
||||
markAllRead,
|
||||
clearHistory,
|
||||
} from '@agor/stores/notifications.svelte';
|
||||
} from '@agor/stores';
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ export {
|
|||
setTheme,
|
||||
setFlavor,
|
||||
initTheme,
|
||||
} from '@agor/stores/theme.svelte';
|
||||
} from '@agor/stores';
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@
|
|||
"moduleDetection": "force",
|
||||
"paths": {
|
||||
"@agor/types": ["./packages/types/index.ts"],
|
||||
"@agor/types/*": ["./packages/types/*.ts"]
|
||||
"@agor/types/*": ["./packages/types/*.ts"],
|
||||
"@agor/stores": ["./packages/stores/index.ts"],
|
||||
"@agor/stores/*": ["./packages/stores/*.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "packages/types/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "packages/types/**/*.ts", "packages/stores/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,6 +334,42 @@ export class BtmsgDb {
|
|||
return id;
|
||||
}
|
||||
|
||||
// ── Feature 7: Channel membership management ─────────────────────────────
|
||||
|
||||
joinChannel(channelId: string, agentId: string): void {
|
||||
// Validate channel exists
|
||||
const ch = this.db.query<{ id: string }, [string]>(
|
||||
"SELECT id FROM channels WHERE id = ?"
|
||||
).get(channelId);
|
||||
if (!ch) throw new Error(`Channel '${channelId}' not found`);
|
||||
|
||||
this.db.query(
|
||||
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)"
|
||||
).run(channelId, agentId);
|
||||
}
|
||||
|
||||
leaveChannel(channelId: string, agentId: string): void {
|
||||
this.db.query(
|
||||
"DELETE FROM channel_members WHERE channel_id = ? AND agent_id = ?"
|
||||
).run(channelId, agentId);
|
||||
}
|
||||
|
||||
getChannelMembers(channelId: string): Array<{ agentId: string; name: string; role: string }> {
|
||||
return this.db.query<{
|
||||
agent_id: string; name: string; role: string;
|
||||
}, [string]>(
|
||||
`SELECT cm.agent_id, a.name, a.role
|
||||
FROM channel_members cm
|
||||
JOIN agents a ON cm.agent_id = a.id
|
||||
WHERE cm.channel_id = ?
|
||||
ORDER BY a.name`
|
||||
).all(channelId).map(r => ({
|
||||
agentId: r.agent_id,
|
||||
name: r.name,
|
||||
role: r.role,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Heartbeats ───────────────────────────────────────────────────────────
|
||||
|
||||
heartbeat(agentId: string): void {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
/**
|
||||
* btmsg + bttask RPC handlers.
|
||||
* Feature 4: Push events on data changes (bttask.changed, btmsg.newMessage).
|
||||
*/
|
||||
|
||||
import type { BtmsgDb } from "../btmsg-db.ts";
|
||||
import type { BttaskDb } from "../bttask-db.ts";
|
||||
|
||||
export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
|
||||
type RpcSend = { send: Record<string, (...args: unknown[]) => void> };
|
||||
|
||||
export function createBtmsgHandlers(btmsgDb: BtmsgDb, rpcRef?: RpcSend) {
|
||||
function pushNewMessage(groupId: string, channelId?: string) {
|
||||
try { rpcRef?.send?.["btmsg.newMessage"]?.({ groupId, channelId }); } catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
return {
|
||||
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record<string, unknown>) => {
|
||||
try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; }
|
||||
|
|
@ -16,7 +23,13 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
|
|||
catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; }
|
||||
},
|
||||
"btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => {
|
||||
try { const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); return { ok: true, messageId }; }
|
||||
try {
|
||||
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
|
||||
// Feature 4: Push DM notification
|
||||
const sender = btmsgDb.getAgents("").find(a => a.id === fromAgent);
|
||||
pushNewMessage(sender?.groupId ?? "");
|
||||
return { ok: true, messageId };
|
||||
}
|
||||
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.sendMessage]", err); return { ok: false, error }; }
|
||||
},
|
||||
"btmsg.listMessages": ({ agentId, otherId, limit }: { agentId: string; otherId: string; limit?: number }) => {
|
||||
|
|
@ -40,9 +53,29 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
|
|||
catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; }
|
||||
},
|
||||
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => {
|
||||
try { const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); return { ok: true, messageId }; }
|
||||
try {
|
||||
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
|
||||
// Feature 4: Push channel message notification
|
||||
const channels = btmsgDb.listChannels("");
|
||||
const ch = channels.find(c => c.id === channelId);
|
||||
pushNewMessage(ch?.groupId ?? "", channelId);
|
||||
return { ok: true, messageId };
|
||||
}
|
||||
catch (err) { console.error("[btmsg.sendChannelMessage]", err); return { ok: false }; }
|
||||
},
|
||||
// Feature 7: Join/leave channel membership
|
||||
"btmsg.joinChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
|
||||
try { btmsgDb.joinChannel(channelId, agentId); return { ok: true }; }
|
||||
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.joinChannel]", err); return { ok: false, error }; }
|
||||
},
|
||||
"btmsg.leaveChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
|
||||
try { btmsgDb.leaveChannel(channelId, agentId); return { ok: true }; }
|
||||
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.leaveChannel]", err); return { ok: false, error }; }
|
||||
},
|
||||
"btmsg.getChannelMembers": ({ channelId }: { channelId: string }) => {
|
||||
try { return { members: btmsgDb.getChannelMembers(channelId) }; }
|
||||
catch (err) { console.error("[btmsg.getChannelMembers]", err); return { members: [] }; }
|
||||
},
|
||||
"btmsg.heartbeat": ({ agentId }: { agentId: string }) => {
|
||||
try { btmsgDb.heartbeat(agentId); return { ok: true }; }
|
||||
catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; }
|
||||
|
|
@ -62,22 +95,34 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
|
|||
};
|
||||
}
|
||||
|
||||
export function createBttaskHandlers(bttaskDb: BttaskDb) {
|
||||
export function createBttaskHandlers(bttaskDb: BttaskDb, rpcRef?: RpcSend) {
|
||||
function pushChanged(groupId: string) {
|
||||
try { rpcRef?.send?.["bttask.changed"]?.({ groupId }); } catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
return {
|
||||
"bttask.listTasks": ({ groupId }: { groupId: string }) => {
|
||||
try { return { tasks: bttaskDb.listTasks(groupId) }; }
|
||||
catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; }
|
||||
},
|
||||
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record<string, unknown>) => {
|
||||
try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); return { ok: true, taskId }; }
|
||||
try {
|
||||
const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string);
|
||||
pushChanged(groupId as string);
|
||||
return { ok: true, taskId };
|
||||
}
|
||||
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.createTask]", err); return { ok: false, error }; }
|
||||
},
|
||||
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }: { taskId: string; status: string; expectedVersion: number }) => {
|
||||
try { const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); return { ok: true, newVersion }; }
|
||||
try {
|
||||
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
|
||||
pushChanged(""); // groupId unknown here, frontend will reload
|
||||
return { ok: true, newVersion };
|
||||
}
|
||||
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[bttask.updateTaskStatus]", err); return { ok: false, error }; }
|
||||
},
|
||||
"bttask.deleteTask": ({ taskId }: { taskId: string }) => {
|
||||
try { bttaskDb.deleteTask(taskId); return { ok: true }; }
|
||||
try { bttaskDb.deleteTask(taskId); pushChanged(""); return { ok: true }; }
|
||||
catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; }
|
||||
},
|
||||
"bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
*/
|
||||
|
||||
import type { RelayClient } from "../relay-client.ts";
|
||||
import type { SettingsDb } from "../settings-db.ts";
|
||||
|
||||
export function createRemoteHandlers(relayClient: RelayClient) {
|
||||
export function createRemoteHandlers(relayClient: RelayClient, settingsDb?: SettingsDb) {
|
||||
return {
|
||||
// Fix #4 (Codex audit): relay-client.connect() now returns { ok, machineId, error }
|
||||
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
|
||||
|
|
@ -74,5 +75,23 @@ export function createRemoteHandlers(relayClient: RelayClient) {
|
|||
return { status: "error" as const, latencyMs: null, error };
|
||||
}
|
||||
},
|
||||
|
||||
// Feature 3: Remote credential vault
|
||||
"remote.getStoredCredentials": () => {
|
||||
if (!settingsDb) return { credentials: [] };
|
||||
return { credentials: settingsDb.listRelayCredentials() };
|
||||
},
|
||||
|
||||
"remote.storeCredential": ({ url, token, label }: { url: string; token: string; label?: string }) => {
|
||||
if (!settingsDb) return { ok: false };
|
||||
try { settingsDb.storeRelayCredential(url, token, label); return { ok: true }; }
|
||||
catch (err) { console.error("[remote.storeCredential]", err); return { ok: false }; }
|
||||
},
|
||||
|
||||
"remote.deleteCredential": ({ url }: { url: string }) => {
|
||||
if (!settingsDb) return { ok: false };
|
||||
try { settingsDb.deleteRelayCredential(url); return { ok: true }; }
|
||||
catch (err) { console.error("[remote.deleteCredential]", err); return { ok: false }; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,11 +94,11 @@ const ptyHandlers = createPtyHandlers(ptyClient);
|
|||
const filesHandlers = createFilesHandlers();
|
||||
const settingsHandlers = createSettingsHandlers(settingsDb);
|
||||
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef);
|
||||
const btmsgHandlers = createBtmsgHandlers(btmsgDb);
|
||||
const bttaskHandlers = createBttaskHandlers(bttaskDb);
|
||||
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
|
||||
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
|
||||
const searchHandlers = createSearchHandlers(searchDb);
|
||||
const pluginHandlers = createPluginHandlers();
|
||||
const remoteHandlers = createRemoteHandlers(relayClient);
|
||||
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
|
||||
|
||||
// ── RPC definition ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -218,6 +218,17 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
} catch (err) { console.error("[memora.list]", err); return { memories: [] }; }
|
||||
},
|
||||
|
||||
// ── Feature 8: Diagnostics ─────────────────────────────────────────
|
||||
"diagnostics.stats": () => {
|
||||
return {
|
||||
ptyConnected: ptyClient.isConnected,
|
||||
relayConnections: relayClient.listMachines().filter(m => m.status === "connected").length,
|
||||
activeSidecars: sidecarManager.listSessions().filter(s => s.status === "running").length,
|
||||
rpcCallCount: 0, // Placeholder — Electrobun doesn't expose RPC call count
|
||||
droppedEvents: 0,
|
||||
};
|
||||
},
|
||||
|
||||
// ── Telemetry ─────────────────────────────────────────────────────
|
||||
"telemetry.log": ({ level, message, attributes }) => {
|
||||
try { telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); return { ok: true }; }
|
||||
|
|
|
|||
|
|
@ -265,6 +265,70 @@ export class SettingsDb {
|
|||
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
// ── Remote credential vault (Feature 3) ──────────────────────────────────
|
||||
|
||||
private getMachineKey(): string {
|
||||
try {
|
||||
const h = require("os").hostname();
|
||||
return h || "agor-default-key";
|
||||
} catch {
|
||||
return "agor-default-key";
|
||||
}
|
||||
}
|
||||
|
||||
private xorObfuscate(text: string, key: string): string {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result.push(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
||||
}
|
||||
return Buffer.from(result).toString("base64");
|
||||
}
|
||||
|
||||
private xorDeobfuscate(encoded: string, key: string): string {
|
||||
const buf = Buffer.from(encoded, "base64");
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
result.push(String.fromCharCode(buf[i] ^ key.charCodeAt(i % key.length)));
|
||||
}
|
||||
return result.join("");
|
||||
}
|
||||
|
||||
storeRelayCredential(url: string, token: string, label?: string): void {
|
||||
const key = this.getMachineKey();
|
||||
const obfuscated = this.xorObfuscate(token, key);
|
||||
const data = JSON.stringify({ url, token: obfuscated, label: label ?? url });
|
||||
this.setSetting(`relay_cred_${url}`, data);
|
||||
}
|
||||
|
||||
getRelayCredential(url: string): { url: string; token: string; label: string } | null {
|
||||
const raw = this.getSetting(`relay_cred_${url}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const data = JSON.parse(raw) as { url: string; token: string; label: string };
|
||||
const key = this.getMachineKey();
|
||||
return { url: data.url, token: this.xorDeobfuscate(data.token, key), label: data.label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
listRelayCredentials(): Array<{ url: string; label: string }> {
|
||||
const all = this.getAll();
|
||||
const results: Array<{ url: string; label: string }> = [];
|
||||
for (const [k, v] of Object.entries(all)) {
|
||||
if (!k.startsWith("relay_cred_")) continue;
|
||||
try {
|
||||
const data = JSON.parse(v) as { url: string; label: string };
|
||||
results.push({ url: data.url, label: data.label });
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
deleteRelayCredential(url: string): void {
|
||||
this.db.query("DELETE FROM settings WHERE key = ?").run(`relay_cred_${url}`);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
close(): void {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ function findNodeRuntime(): string {
|
|||
const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session
|
||||
// Fix #12 (Codex audit): Max NDJSON line size — prevent OOM on malformed output
|
||||
const MAX_LINE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
// Feature 5: Max total pending stdout buffer per session (50 MB)
|
||||
const MAX_PENDING_BUFFER = 50 * 1024 * 1024;
|
||||
|
||||
// ── SidecarManager ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -378,6 +380,13 @@ export class SidecarManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Feature 5: Backpressure guard — pause if total buffer exceeds 50MB
|
||||
if (buffer.length > MAX_PENDING_BUFFER) {
|
||||
console.warn(`[sidecar] Buffer exceeded ${MAX_PENDING_BUFFER} bytes for ${sessionId}, pausing read`);
|
||||
// Drain what we can and skip the rest
|
||||
buffer = buffer.slice(-MAX_LINE_SIZE);
|
||||
}
|
||||
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx).trim();
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@
|
|||
let dmMessages = $state<DM[]>([]);
|
||||
let input = $state('');
|
||||
let loading = $state(false);
|
||||
// Feature 7: Channel member list
|
||||
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
|
||||
let showMembers = $state(false);
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -119,7 +122,19 @@
|
|||
|
||||
function selectChannel(id: string) {
|
||||
activeChannelId = id;
|
||||
showMembers = false;
|
||||
loadChannelMessages(id);
|
||||
loadChannelMembers(id);
|
||||
}
|
||||
|
||||
// Feature 7: Load channel members
|
||||
async function loadChannelMembers(channelId: string) {
|
||||
try {
|
||||
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
|
||||
channelMembers = res.members;
|
||||
} catch (err) {
|
||||
console.error('[CommsTab] loadChannelMembers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDm(otherId: string) {
|
||||
|
|
@ -156,22 +171,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── Init + polling ───────────────────────────────────────────────────
|
||||
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Feature 4: Listen for push events
|
||||
function onNewMessage(payload: { groupId: string; channelId?: string }) {
|
||||
if (mode === 'channels' && activeChannelId) {
|
||||
if (!payload.channelId || payload.channelId === activeChannelId) {
|
||||
loadChannelMessages(activeChannelId);
|
||||
}
|
||||
} else if (mode === 'dms' && activeDmAgentId) {
|
||||
loadDmMessages(activeDmAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadChannels();
|
||||
loadAgents();
|
||||
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
|
||||
// Feature 4: Fallback 30s poll for missed events
|
||||
pollTimer = setInterval(() => {
|
||||
if (mode === 'channels' && activeChannelId) {
|
||||
loadChannelMessages(activeChannelId);
|
||||
} else if (mode === 'dms' && activeDmAgentId) {
|
||||
loadDmMessages(activeDmAgentId);
|
||||
}
|
||||
}, 5000);
|
||||
}, 30000);
|
||||
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
return () => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -264,6 +295,20 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Feature 7: Channel member list toggle -->
|
||||
{#if mode === 'channels' && activeChannelId}
|
||||
<button class="members-toggle" onclick={() => showMembers = !showMembers}>
|
||||
Members ({channelMembers.length})
|
||||
</button>
|
||||
{#if showMembers}
|
||||
<div class="members-list">
|
||||
{#each channelMembers as m}
|
||||
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="msg-input-bar">
|
||||
<input
|
||||
|
|
@ -518,4 +563,42 @@
|
|||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Feature 7: Member list */
|
||||
.members-toggle {
|
||||
background: var(--ctp-surface0);
|
||||
border: none;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.members-toggle:hover { color: var(--ctp-text); }
|
||||
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.member-chip {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-mantle);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.member-role {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@
|
|||
fileEncoding = result.encoding;
|
||||
fileSize = result.size;
|
||||
editorContent = fileContent;
|
||||
// Feature 2: Record mtime at read time
|
||||
try {
|
||||
const stat = await appRpc.request["files.stat"]({ path: filePath });
|
||||
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
|
||||
} catch { /* non-critical */ }
|
||||
} catch (err) {
|
||||
if (token !== fileRequestToken) return;
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -178,9 +183,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Save current file. */
|
||||
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
|
||||
async function saveFile() {
|
||||
if (!selectedFile || !isDirty) return;
|
||||
try {
|
||||
// Feature 2: Check if file was modified externally since we read it
|
||||
if (readMtimeMs > 0) {
|
||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
||||
if (!stat.error && stat.mtimeMs > readMtimeMs) {
|
||||
showConflictDialog = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await doSave();
|
||||
} catch (err) {
|
||||
console.error('[files.write]', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-save, bypassing conflict check. */
|
||||
async function doSave() {
|
||||
if (!selectedFile) return;
|
||||
try {
|
||||
const result = await appRpc.request["files.write"]({
|
||||
path: selectedFile,
|
||||
|
|
@ -189,6 +212,10 @@
|
|||
if (result.ok) {
|
||||
isDirty = false;
|
||||
fileContent = editorContent;
|
||||
showConflictDialog = false;
|
||||
// Update mtime after successful save
|
||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
||||
if (!stat.error) readMtimeMs = stat.mtimeMs;
|
||||
} else if (result.error) {
|
||||
console.error('[files.write]', result.error);
|
||||
}
|
||||
|
|
@ -197,6 +224,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Reload file from disk (discard local changes). */
|
||||
async function reloadFile() {
|
||||
showConflictDialog = false;
|
||||
if (selectedFile) {
|
||||
isDirty = false;
|
||||
const saved = selectedFile;
|
||||
selectedFile = null;
|
||||
await selectFile(saved);
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorChange(newContent: string) {
|
||||
editorContent = newContent;
|
||||
isDirty = newContent !== fileContent;
|
||||
|
|
@ -282,6 +320,21 @@
|
|||
{@render renderEntries(cwd, 0)}
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: Conflict dialog -->
|
||||
{#if showConflictDialog}
|
||||
<div class="conflict-overlay">
|
||||
<div class="conflict-dialog">
|
||||
<p class="conflict-title">File modified externally</p>
|
||||
<p class="conflict-desc">This file was changed on disk since you opened it.</p>
|
||||
<div class="conflict-actions">
|
||||
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
|
||||
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
|
||||
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Viewer panel -->
|
||||
<div class="fb-viewer">
|
||||
{#if !selectedFile}
|
||||
|
|
@ -346,6 +399,7 @@
|
|||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Tree panel ── */
|
||||
|
|
@ -521,4 +575,58 @@
|
|||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Feature 2: Conflict dialog */
|
||||
.conflict-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conflict-dialog {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.conflict-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.conflict-desc {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.conflict-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.conflict-btn {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conflict-btn.overwrite { border-color: var(--ctp-red); color: var(--ctp-red); }
|
||||
.conflict-btn.reload { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.conflict-btn:hover { background: var(--ctp-surface1); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import MarketplaceTab from './settings/MarketplaceTab.svelte';
|
||||
import KeyboardSettings from './settings/KeyboardSettings.svelte';
|
||||
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
|
||||
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard';
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard' | 'diagnostics';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
|
||||
{ id: 'diagnostics', label: 'Diagnostics', icon: '📊' },
|
||||
];
|
||||
|
||||
let activeCategory = $state<CategoryId>('appearance');
|
||||
|
|
@ -105,6 +107,8 @@
|
|||
<KeyboardSettings />
|
||||
{:else if activeCategory === 'marketplace'}
|
||||
<MarketplaceTab />
|
||||
{:else if activeCategory === 'diagnostics'}
|
||||
<DiagnosticsTab />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -168,14 +168,24 @@
|
|||
dragOverCol = null;
|
||||
}
|
||||
|
||||
// ── Init + polling ───────────────────────────────────────────────────
|
||||
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Feature 4: Listen for push events, fallback to 30s poll
|
||||
function onTaskChanged(payload: { groupId: string }) {
|
||||
if (!payload.groupId || payload.groupId === groupId) loadTasks();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTasks();
|
||||
pollTimer = setInterval(loadTasks, 5000);
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
appRpc.addMessageListener('bttask.changed', onTaskChanged);
|
||||
// Feature 4: Fallback 30s poll for missed events
|
||||
pollTimer = setInterval(loadTasks, 30000);
|
||||
return () => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
appRpc.removeMessageListener?.('bttask.changed', onTaskChanged);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -136,8 +136,16 @@
|
|||
|
||||
// ── Send user input to daemon ──────────────────────────────────────────
|
||||
|
||||
// Feature 5: Max terminal paste chunk (64KB) — truncate with warning
|
||||
const MAX_PASTE_CHUNK = 64 * 1024;
|
||||
term.onData((data: string) => {
|
||||
appRpc.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
|
||||
let payload = data;
|
||||
if (payload.length > MAX_PASTE_CHUNK) {
|
||||
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
|
||||
payload = payload.slice(0, MAX_PASTE_CHUNK);
|
||||
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
|
||||
}
|
||||
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
|
||||
console.error('[pty.write] error:', err);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
271
ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte
Normal file
271
ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -413,6 +413,21 @@ export type PtyRPCRequests = {
|
|||
}>;
|
||||
};
|
||||
};
|
||||
/** Feature 7: Join a channel. */
|
||||
"btmsg.joinChannel": {
|
||||
params: { channelId: string; agentId: string };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** Feature 7: Leave a channel. */
|
||||
"btmsg.leaveChannel": {
|
||||
params: { channelId: string; agentId: string };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** Feature 7: Get channel member list. */
|
||||
"btmsg.getChannelMembers": {
|
||||
params: { channelId: string };
|
||||
response: { members: Array<{ agentId: string; name: string; role: string }> };
|
||||
};
|
||||
/** Send a channel message. */
|
||||
"btmsg.sendChannelMessage": {
|
||||
params: { channelId: string; fromAgent: string; content: string };
|
||||
|
|
@ -587,6 +602,21 @@ export type PtyRPCRequests = {
|
|||
params: { machineId: string; command: string; payload: Record<string, unknown> };
|
||||
response: { ok: boolean; error?: string };
|
||||
};
|
||||
/** Feature 3: Get stored relay credentials. */
|
||||
"remote.getStoredCredentials": {
|
||||
params: Record<string, never>;
|
||||
response: { credentials: Array<{ url: string; label: string }> };
|
||||
};
|
||||
/** Feature 3: Store a relay credential (XOR-obfuscated). */
|
||||
"remote.storeCredential": {
|
||||
params: { url: string; token: string; label?: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Feature 3: Delete a stored relay credential. */
|
||||
"remote.deleteCredential": {
|
||||
params: { url: string };
|
||||
response: { ok: boolean };
|
||||
};
|
||||
/** Get the status of a specific machine. */
|
||||
"remote.status": {
|
||||
params: { machineId: string };
|
||||
|
|
@ -599,6 +629,18 @@ export type PtyRPCRequests = {
|
|||
|
||||
// ── Telemetry RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Feature 8: Transport diagnostics stats. */
|
||||
"diagnostics.stats": {
|
||||
params: Record<string, never>;
|
||||
response: {
|
||||
ptyConnected: boolean;
|
||||
relayConnections: number;
|
||||
activeSidecars: number;
|
||||
rpcCallCount: number;
|
||||
droppedEvents: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** Log a telemetry event from the frontend. */
|
||||
"telemetry.log": {
|
||||
params: {
|
||||
|
|
@ -680,6 +722,12 @@ export type PtyRPCMessages = {
|
|||
status: "connecting" | "connected" | "disconnected" | "error";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Feature 4: Push-based task/relay updates
|
||||
/** Task board data changed (created, moved, deleted). */
|
||||
"bttask.changed": { groupId: string };
|
||||
/** New btmsg channel or DM message. */
|
||||
"btmsg.newMessage": { groupId: string; channelId?: string };
|
||||
};
|
||||
|
||||
// ── Combined schema ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
'@agor/types': path.resolve(__dirname, 'packages/types/index.ts'),
|
||||
'@agor/stores': path.resolve(__dirname, 'packages/stores/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
@ -17,6 +18,7 @@ export default defineConfig({
|
|||
test: {
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'packages/**/*.test.ts',
|
||||
'sidecar/**/*.test.ts',
|
||||
...(process.env.AGOR_EDITION === 'pro' ? ['tests/commercial/**/*.test.ts'] : []),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue