From f0850f0785a4b475e54e73f593d76c656b3d6164 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 22 Mar 2026 04:45:56 +0100 Subject: [PATCH] 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) --- .../backend/__tests__/backend-adapter.test.ts | 91 ++++++ .../__tests__/electrobun-adapter.test.ts | 246 ++++++++++++++++ .../backend/__tests__/tauri-adapter.test.ts | 268 +++++++++++++++++ src/lib/stores/health.svelte.ts | 2 +- src/lib/stores/notifications.svelte.ts | 2 +- src/lib/stores/theme.svelte.ts | 2 +- tsconfig.app.json | 6 +- ui-electrobun/src/bun/btmsg-db.ts | 36 +++ .../src/bun/handlers/btmsg-handlers.ts | 59 +++- .../src/bun/handlers/remote-handlers.ts | 21 +- ui-electrobun/src/bun/index.ts | 17 +- ui-electrobun/src/bun/settings-db.ts | 64 +++++ ui-electrobun/src/bun/sidecar-manager.ts | 9 + ui-electrobun/src/mainview/CommsTab.svelte | 89 +++++- ui-electrobun/src/mainview/FileBrowser.svelte | 110 ++++++- .../src/mainview/SettingsDrawer.svelte | 6 +- .../src/mainview/TaskBoardTab.svelte | 16 +- ui-electrobun/src/mainview/Terminal.svelte | 10 +- .../mainview/settings/DiagnosticsTab.svelte | 271 ++++++++++++++++++ .../mainview/settings/ProjectSettings.svelte | 39 +++ ui-electrobun/src/shared/pty-rpc-schema.ts | 48 ++++ vite.config.ts | 2 + 22 files changed, 1389 insertions(+), 25 deletions(-) create mode 100644 src/lib/backend/__tests__/backend-adapter.test.ts create mode 100644 src/lib/backend/__tests__/electrobun-adapter.test.ts create mode 100644 src/lib/backend/__tests__/tauri-adapter.test.ts create mode 100644 ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte diff --git a/src/lib/backend/__tests__/backend-adapter.test.ts b/src/lib/backend/__tests__/backend-adapter.test.ts new file mode 100644 index 0000000..303e468 --- /dev/null +++ b/src/lib/backend/__tests__/backend-adapter.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/backend/__tests__/electrobun-adapter.test.ts b/src/lib/backend/__tests__/electrobun-adapter.test.ts new file mode 100644 index 0000000..6a98f06 --- /dev/null +++ b/src/lib/backend/__tests__/electrobun-adapter.test.ts @@ -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> = {}; + 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; + +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); + }); + }); +}); diff --git a/src/lib/backend/__tests__/tauri-adapter.test.ts b/src/lib/backend/__tests__/tauri-adapter.test.ts new file mode 100644 index 0000000..e8b648c --- /dev/null +++ b/src/lib/backend/__tests__/tauri-adapter.test.ts @@ -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 }); + }); + }); +}); diff --git a/src/lib/stores/health.svelte.ts b/src/lib/stores/health.svelte.ts index 426803e..7242d62 100644 --- a/src/lib/stores/health.svelte.ts +++ b/src/lib/stores/health.svelte.ts @@ -18,4 +18,4 @@ export { getAllProjectHealth, getAttentionQueue, getHealthAggregates, -} from '@agor/stores/health.svelte'; +} from '@agor/stores'; diff --git a/src/lib/stores/notifications.svelte.ts b/src/lib/stores/notifications.svelte.ts index b5f7196..e35bcb7 100644 --- a/src/lib/stores/notifications.svelte.ts +++ b/src/lib/stores/notifications.svelte.ts @@ -13,4 +13,4 @@ export { markRead, markAllRead, clearHistory, -} from '@agor/stores/notifications.svelte'; +} from '@agor/stores'; diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 212cd0d..4829e18 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -11,4 +11,4 @@ export { setTheme, setFlavor, initTheme, -} from '@agor/stores/theme.svelte'; +} from '@agor/stores'; diff --git a/tsconfig.app.json b/tsconfig.app.json index 89b876c..fcb6b09 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -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"] } diff --git a/ui-electrobun/src/bun/btmsg-db.ts b/ui-electrobun/src/bun/btmsg-db.ts index 53d4b1e..5049d97 100644 --- a/ui-electrobun/src/bun/btmsg-db.ts +++ b/ui-electrobun/src/bun/btmsg-db.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 { diff --git a/ui-electrobun/src/bun/handlers/btmsg-handlers.ts b/ui-electrobun/src/bun/handlers/btmsg-handlers.ts index bf80b90..3f095e1 100644 --- a/ui-electrobun/src/bun/handlers/btmsg-handlers.ts +++ b/ui-electrobun/src/bun/handlers/btmsg-handlers.ts @@ -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 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) => { 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) => { - 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 }) => { diff --git a/ui-electrobun/src/bun/handlers/remote-handlers.ts b/ui-electrobun/src/bun/handlers/remote-handlers.ts index faba79f..7693867 100644 --- a/ui-electrobun/src/bun/handlers/remote-handlers.ts +++ b/ui-electrobun/src/bun/handlers/remote-handlers.ts @@ -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 }; } + }, }; } diff --git a/ui-electrobun/src/bun/index.ts b/ui-electrobun/src/bun/index.ts index 8ce5643..5be798a 100644 --- a/ui-electrobun/src/bun/index.ts +++ b/ui-electrobun/src/bun/index.ts @@ -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({ } 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 }; } diff --git a/ui-electrobun/src/bun/settings-db.ts b/ui-electrobun/src/bun/settings-db.ts index 1485646..a3d399a 100644 --- a/ui-electrobun/src/bun/settings-db.ts +++ b/ui-electrobun/src/bun/settings-db.ts @@ -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 { diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts index 6b9feeb..2e7ab92 100644 --- a/ui-electrobun/src/bun/sidecar-manager.ts +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -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(); diff --git a/ui-electrobun/src/mainview/CommsTab.svelte b/ui-electrobun/src/mainview/CommsTab.svelte index 4977977..2e676f7 100644 --- a/ui-electrobun/src/mainview/CommsTab.svelte +++ b/ui-electrobun/src/mainview/CommsTab.svelte @@ -64,6 +64,9 @@ let dmMessages = $state([]); let input = $state(''); let loading = $state(false); + // Feature 7: Channel member list + let channelMembers = $state>([]); + 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 | 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); + }; }); @@ -264,6 +295,20 @@ {/if} + + {#if mode === 'channels' && activeChannelId} + + {#if showMembers} +
+ {#each channelMembers as m} + {m.name} {m.role} + {/each} +
+ {/if} + {/if} +
diff --git a/ui-electrobun/src/mainview/FileBrowser.svelte b/ui-electrobun/src/mainview/FileBrowser.svelte index fb90b05..6a6452e 100644 --- a/ui-electrobun/src/mainview/FileBrowser.svelte +++ b/ui-electrobun/src/mainview/FileBrowser.svelte @@ -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)}
+ + {#if showConflictDialog} +
+
+

File modified externally

+

This file was changed on disk since you opened it.

+
+ + + +
+
+
+ {/if} +
{#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); } diff --git a/ui-electrobun/src/mainview/SettingsDrawer.svelte b/ui-electrobun/src/mainview/SettingsDrawer.svelte index 2211c05..14c2d89 100644 --- a/ui-electrobun/src/mainview/SettingsDrawer.svelte +++ b/ui-electrobun/src/mainview/SettingsDrawer.svelte @@ -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('appearance'); @@ -105,6 +107,8 @@ {:else if activeCategory === 'marketplace'} + {:else if activeCategory === 'diagnostics'} + {/if}
diff --git a/ui-electrobun/src/mainview/TaskBoardTab.svelte b/ui-electrobun/src/mainview/TaskBoardTab.svelte index 3d9a4b7..5ab7b99 100644 --- a/ui-electrobun/src/mainview/TaskBoardTab.svelte +++ b/ui-electrobun/src/mainview/TaskBoardTab.svelte @@ -168,14 +168,24 @@ dragOverCol = null; } - // ── Init + polling ─────────────────────────────────────────────────── + // ── Init + event-driven updates (Feature 4) ───────────────────────── let pollTimer: ReturnType | 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); + }; }); diff --git a/ui-electrobun/src/mainview/Terminal.svelte b/ui-electrobun/src/mainview/Terminal.svelte index af9e3ef..e84e547 100644 --- a/ui-electrobun/src/mainview/Terminal.svelte +++ b/ui-electrobun/src/mainview/Terminal.svelte @@ -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); }); }); diff --git a/ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte b/ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte new file mode 100644 index 0000000..a48961c --- /dev/null +++ b/ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte @@ -0,0 +1,271 @@ + + +
+

Transport Diagnostics

+ + +
+

Connections

+
+ PTY daemon + + {ptyConnected ? 'Connected' : 'Disconnected'} + + + Relay connections + {relayConnections} + + Active sidecars + {activeSidecars} +
+
+ + +
+

Agent fleet

+
+ Running + {health.running} + + Idle + {health.idle} + + Stalled + 0}>{health.stalled} + + Burn rate + ${health.totalBurnRatePerHour.toFixed(2)}/hr +
+
+ + + {#if activeTools.length > 0} +
+

Active tools

+
+ {#each activeTools as tool} +
+ {tool.toolName} + {formatDuration(Date.now() - tool.startTime)} +
+ {/each} +
+
+ {/if} + + + {#if toolHistogram.length > 0} +
+

Tool duration (avg)

+
+ {#each toolHistogram as entry} + {@const maxMs = Math.max(...toolHistogram.map(e => e.avgMs))} +
+ {entry.toolName} +
+
+
+ {formatDuration(entry.avgMs)} ({entry.count}x) +
+ {/each} +
+
+ {/if} + + +
+ + diff --git a/ui-electrobun/src/mainview/settings/ProjectSettings.svelte b/ui-electrobun/src/mainview/settings/ProjectSettings.svelte index 0aa2584..c10ce14 100644 --- a/ui-electrobun/src/mainview/settings/ProjectSettings.svelte +++ b/ui-electrobun/src/mainview/settings/ProjectSettings.svelte @@ -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) { 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 */ } }); @@ -110,6 +132,23 @@ {/each} + +

Session retention

+
+ Keep last + updateRetention('count', parseInt((e.target as HTMLInputElement).value, 10))} + /> + {sessionRetentionCount} +
+
+ Max age + updateRetention('days', parseInt((e.target as HTMLInputElement).value, 10))} + /> + {sessionRetentionDays}d +
+

Custom context