diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index f0ef06d..4d826c9 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -384,7 +384,7 @@ Return status for user-level local LLM backend daemons (for example `ollama.serv #### `system.dockerDependencies` -Return status for docker-compose managed dependencies (currently includes local Whisper service status for voice transcription workflows). +Return status for docker-compose managed dependencies discovered from `docker-compose.yml` (excluding Flynn's own `flynn` service). Includes profile-scoped services (for example `whisper-server`, `brave-search`) when profiles are defined. **Request:** ```json @@ -410,6 +410,17 @@ Return status for docker-compose managed dependencies (currently includes local "statusText": "Up 4 minutes (healthy)", "containerName": "flynn-whisper-server-1", "availableActions": ["restart", "stop", "update"] + }, + { + "id": "brave-search", + "name": "Brave Search", + "service": "brave-search", + "configured": true, + "state": "running", + "health": "healthy", + "statusText": "Up 2 minutes", + "containerName": "brave-search", + "availableActions": ["restart", "stop", "update"] } ] } @@ -420,7 +431,8 @@ Return status for docker-compose managed dependencies (currently includes local Control a docker-compose dependency (`start`, `restart`, `stop`, `update`). -- `update` for `whisper` pulls the latest image and runs `docker compose up -d` to reconcile. +- `dependency` must match an ID returned by `system.dockerDependencies`. +- `update` pulls the latest image for that compose service and runs `docker compose up -d` to reconcile. **Request:** ```json diff --git a/docs/plans/state.json b/docs/plans/state.json index c015ba2..6b48c65 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,20 @@ "updated_at": "2026-02-23", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "dashboard-docker-dependency-discovery": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Replaced whisper-only docker dependency handling with dynamic docker-compose discovery. Gateway now discovers profile-scoped services via `docker compose config --profiles` + `config --services` (with `ps --all` fallback) and returns status/control cards for all non-`flynn` compose dependencies (including `brave-search`). Docker dependency controls now operate generically on discovered service IDs.", + "files_modified": [ + "src/gateway/handlers/dockerDependencies.ts", + "src/gateway/handlers/dockerDependencies.test.ts", + "src/gateway/ui/pages/dashboard.test.ts", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/dockerDependencies.test.ts src/gateway/handlers/handlers.test.ts src/gateway/ui/pages/dashboard.test.ts + pnpm typecheck passing" + }, "brave-search-tooling-docs": { "status": "completed", "date": "2026-02-23", @@ -6163,7 +6177,7 @@ } }, "overall_progress": { - "total_test_count": 1951, + "total_test_count": 1952, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/gateway/handlers/dockerDependencies.test.ts b/src/gateway/handlers/dockerDependencies.test.ts index cf2b2ea..755baf4 100644 --- a/src/gateway/handlers/dockerDependencies.test.ts +++ b/src/gateway/handlers/dockerDependencies.test.ts @@ -2,27 +2,48 @@ import { describe, it, expect } from 'vitest'; import type { Config } from '../../config/index.js'; import { listDockerDependencyStatuses, controlDockerDependency } from './dockerDependencies.js'; -function createConfig(endpoint: string, enabled = true): Config { +function createConfig(params?: { + audioEndpoint?: string; + audioEnabled?: boolean; + webSearchProvider?: 'brave' | 'searxng'; + webSearchApiKey?: string; +}): Config { return { audio: { - enabled, + enabled: params?.audioEnabled ?? true, provider: { type: 'custom', - endpoint, + endpoint: params?.audioEndpoint ?? 'http://localhost:18801/v1/audio/transcriptions', }, }, + web_search: { + provider: params?.webSearchProvider ?? 'brave', + api_key: params?.webSearchApiKey, + endpoint: undefined, + max_results: 5, + }, } as unknown as Config; } +function profileArgs(): string[] { + return ['--profile', 'voice', '--profile', 'search']; +} + describe('listDockerDependencyStatuses', () => { - it('reports whisper as running when compose ps shows active container', async () => { + it('discovers compose services across profiles and reports whisper + brave statuses', async () => { const seenCalls: string[][] = []; const runner = async (args: string[]) => { seenCalls.push(args); - if (args.includes('config')) { - return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + if (args.join(' ') === 'config --profiles') { + return { stdout: 'voice\nsearch\n', stderr: '' }; } - if (args.includes('ps')) { + if (args.join(' ') === '--profile voice --profile search config --services') { + return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search ps --all --format json') { + return { stdout: '[]', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') { return { stdout: JSON.stringify([{ Name: 'flynn-whisper-server-1', @@ -34,16 +55,28 @@ describe('listDockerDependencyStatuses', () => { stderr: '', }; } + if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') { + return { + stdout: JSON.stringify([{ + Name: 'brave-search', + Service: 'brave-search', + State: 'running', + Health: '', + Status: 'Up 2 minutes', + }]), + stderr: '', + }; + } throw new Error(`Unexpected args: ${args.join(' ')}`); }; - const statuses = await listDockerDependencyStatuses( - createConfig('http://localhost:18801/v1/audio/transcriptions'), - runner, - ); - expect(statuses).toHaveLength(1); - expect(statuses[0]).toMatchObject({ + const statuses = await listDockerDependencyStatuses(createConfig(), runner); + expect(statuses).toHaveLength(2); + + const whisper = statuses.find((entry) => entry.id === 'whisper'); + expect(whisper).toMatchObject({ id: 'whisper', + service: 'whisper-server', configured: true, state: 'running', health: 'healthy', @@ -51,86 +84,121 @@ describe('listDockerDependencyStatuses', () => { containerName: 'flynn-whisper-server-1', availableActions: ['restart', 'stop', 'update'], }); - expect(seenCalls[0]).toEqual(['--profile', 'voice', 'config', '--services']); - expect(seenCalls[1]).toEqual(['--profile', 'voice', 'ps', 'whisper-server', '--format', 'json']); + + const brave = statuses.find((entry) => entry.id === 'brave-search'); + expect(brave).toMatchObject({ + id: 'brave-search', + name: 'Brave Search', + service: 'brave-search', + configured: false, + state: 'running', + statusText: 'Up 2 minutes', + containerName: 'brave-search', + availableActions: ['restart', 'stop', 'update'], + }); + + expect(seenCalls[0]).toEqual(['config', '--profiles']); + expect(seenCalls[1]).toEqual([...profileArgs(), 'config', '--services']); }); - it('reports whisper as defined but not started when no container exists yet', async () => { + it('includes runtime services surfaced by compose ps --all even if not listed in config services', async () => { const runner = async (args: string[]) => { - if (args.includes('config')) { - return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + if (args.join(' ') === 'config --profiles') { + return { stdout: '', stderr: '' }; } - if (args.includes('ps')) { + if (args.join(' ') === 'config --services') { + return { stdout: 'flynn\n', stderr: '' }; + } + if (args.join(' ') === 'ps --all --format json') { + return { + stdout: JSON.stringify([{ + Name: 'brave-search', + Service: 'brave-search', + State: 'running', + Status: 'Up 1 minute', + }]), + stderr: '', + }; + } + if (args.join(' ') === 'ps brave-search --format json') { + return { + stdout: JSON.stringify([{ + Name: 'brave-search', + Service: 'brave-search', + State: 'running', + Status: 'Up 1 minute', + }]), + stderr: '', + }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + const statuses = await listDockerDependencyStatuses(createConfig(), runner); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + id: 'brave-search', + service: 'brave-search', + state: 'running', + }); + }); + + it('returns empty list when no compose dependencies are defined', async () => { + const runner = async (args: string[]) => { + if (args.join(' ') === 'config --profiles') { + return { stdout: '', stderr: '' }; + } + if (args.join(' ') === 'config --services') { + return { stdout: 'flynn\n', stderr: '' }; + } + if (args.join(' ') === 'ps --all --format json') { return { stdout: '[]', stderr: '' }; } throw new Error(`Unexpected args: ${args.join(' ')}`); }; - const statuses = await listDockerDependencyStatuses( - createConfig('http://localhost:18801/v1/audio/transcriptions'), - runner, - ); - expect(statuses[0]).toMatchObject({ - id: 'whisper', - state: 'not-created', - statusText: 'defined, not started', - health: 'none', - configured: true, - availableActions: ['start', 'restart', 'update'], - }); + const statuses = await listDockerDependencyStatuses(createConfig(), runner); + expect(statuses).toEqual([]); }); - it('reports whisper service as missing when compose file does not define it', async () => { - const runner = async (args: string[]) => { - if (args.includes('config')) { - return { stdout: 'flynn\n', stderr: '' }; - } - throw new Error(`Unexpected args: ${args.join(' ')}`); - }; - - const statuses = await listDockerDependencyStatuses( - createConfig('http://localhost:18801/v1/audio/transcriptions'), - runner, - ); - expect(statuses[0]).toMatchObject({ - id: 'whisper', - state: 'not-found', - statusText: 'service not defined in docker-compose.yml', - health: 'none', - availableActions: [], - }); - }); - - it('returns unavailable status when docker compose command fails', async () => { + it('returns unavailable status when docker compose discovery fails', async () => { const runner = async () => { throw Object.assign(new Error('spawn docker ENOENT'), { stderr: 'docker: command not found' }); }; - const statuses = await listDockerDependencyStatuses( - createConfig('http://localhost:18801/v1/audio/transcriptions'), - runner, - ); - expect(statuses[0].statusText).toBe('unavailable'); - expect(statuses[0].state).toBe('unavailable'); - expect(statuses[0].availableActions).toEqual([]); - expect(statuses[0].error).toContain('docker: command not found'); + const statuses = await listDockerDependencyStatuses(createConfig(), runner); + expect(statuses).toHaveLength(1); + expect(statuses[0]).toMatchObject({ + id: 'compose', + state: 'unavailable', + statusText: 'unavailable', + availableActions: [], + }); + expect(statuses[0]?.error).toContain('docker: command not found'); }); it('marks whisper as not configured for non-local transcription endpoints', async () => { const runner = async (args: string[]) => { - if (args.includes('config')) { - return { stdout: 'whisper-server\n', stderr: '' }; + if (args.join(' ') === 'config --profiles') { + return { stdout: 'voice\n', stderr: '' }; } - if (args.includes('ps')) { + if (args.join(' ') === '--profile voice config --services') { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice ps --all --format json') { + return { stdout: '[]', stderr: '' }; + } + if (args.join(' ') === '--profile voice ps whisper-server --format json') { return { stdout: '[]', stderr: '' }; } throw new Error(`Unexpected args: ${args.join(' ')}`); }; const statuses = await listDockerDependencyStatuses( - createConfig('https://api.openai.com/v1/audio/transcriptions'), + createConfig({ audioEndpoint: 'https://api.openai.com/v1/audio/transcriptions' }), runner, ); + expect(statuses[0]?.id).toBe('whisper'); expect(statuses[0]?.configured).toBe(false); }); }); @@ -140,13 +208,19 @@ describe('controlDockerDependency', () => { const calls: string[][] = []; const runner = async (args: string[]) => { calls.push(args); - if (args.includes('config')) { - return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + if (args.join(' ') === 'config --profiles') { + return { stdout: 'voice\nsearch\n', stderr: '' }; } - if (args.includes('up')) { + if (args.join(' ') === '--profile voice --profile search config --services') { + return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search ps --all --format json') { + return { stdout: '[]', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search up -d whisper-server') { return { stdout: '', stderr: '' }; } - if (args.includes('ps')) { + if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') { return { stdout: JSON.stringify([{ Name: 'whisper-server', @@ -158,42 +232,55 @@ describe('controlDockerDependency', () => { stderr: '', }; } + if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') { + return { stdout: '[]', stderr: '' }; + } throw new Error(`Unexpected args: ${args.join(' ')}`); }; const result = await controlDockerDependency( - createConfig('http://localhost:18801/v1/audio/transcriptions'), + createConfig(), 'whisper', 'start', runner, ); expect(result.action).toBe('start'); + expect(result.dependency).toBe('whisper'); expect(result.status.state).toBe('running'); expect(result.status.availableActions).toEqual(['restart', 'stop', 'update']); - expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']); + expect(result.message).toContain('Started whisper-server'); + expect(calls).toContainEqual([...profileArgs(), 'up', '-d', 'whisper-server']); }); - it('updates whisper by pulling image then reconciling container', async () => { + it('updates brave-search by pulling image then reconciling container', async () => { const calls: string[][] = []; const runner = async (args: string[]) => { calls.push(args); - if (args.includes('config')) { - return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + if (args.join(' ') === 'config --profiles') { + return { stdout: 'voice\nsearch\n', stderr: '' }; } - if (args.includes('pull')) { + if (args.join(' ') === '--profile voice --profile search config --services') { + return { stdout: 'flynn\nwhisper-server\nbrave-search\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search ps --all --format json') { + return { stdout: '[]', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search pull brave-search') { return { stdout: 'Pulled', stderr: '' }; } - if (args.includes('up')) { + if (args.join(' ') === '--profile voice --profile search up -d brave-search') { return { stdout: 'Started', stderr: '' }; } - if (args.includes('ps')) { + if (args.join(' ') === '--profile voice --profile search ps whisper-server --format json') { + return { stdout: '[]', stderr: '' }; + } + if (args.join(' ') === '--profile voice --profile search ps brave-search --format json') { return { stdout: JSON.stringify([{ - Name: 'whisper-server', - Service: 'whisper-server', + Name: 'brave-search', + Service: 'brave-search', State: 'running', - Health: 'healthy', - Status: 'Up 1 minute (healthy)', + Status: 'Up 1 minute', }]), stderr: '', }; @@ -202,14 +289,37 @@ describe('controlDockerDependency', () => { }; const result = await controlDockerDependency( - createConfig('http://localhost:18801/v1/audio/transcriptions'), - 'whisper', + createConfig(), + 'brave-search', 'update', runner, ); expect(result.action).toBe('update'); - expect(result.message).toContain('Pulled latest whisper image'); - expect(calls).toContainEqual(['--profile', 'voice', 'pull', 'whisper-server']); - expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']); + expect(result.dependency).toBe('brave-search'); + expect(result.message).toContain('Pulled latest brave-search image'); + expect(calls).toContainEqual([...profileArgs(), 'pull', 'brave-search']); + expect(calls).toContainEqual([...profileArgs(), 'up', '-d', 'brave-search']); + }); + + it('rejects unsupported dependency ids', async () => { + const runner = async (args: string[]) => { + if (args.join(' ') === 'config --profiles') { + return { stdout: 'voice\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice config --services') { + return { stdout: 'flynn\nwhisper-server\n', stderr: '' }; + } + if (args.join(' ') === '--profile voice ps --all --format json') { + return { stdout: '[]', stderr: '' }; + } + throw new Error(`Unexpected args: ${args.join(' ')}`); + }; + + await expect(controlDockerDependency( + createConfig(), + 'not-real', + 'restart', + runner, + )).rejects.toThrow('Unsupported dependency'); }); }); diff --git a/src/gateway/handlers/dockerDependencies.ts b/src/gateway/handlers/dockerDependencies.ts index 9998560..88d8a7e 100644 --- a/src/gateway/handlers/dockerDependencies.ts +++ b/src/gateway/handlers/dockerDependencies.ts @@ -4,7 +4,7 @@ import type { Config } from '../../config/index.js'; const execFile = promisify(execFileCb); -export type DockerDependencyId = 'whisper'; +export type DockerDependencyId = string; export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update'; export interface DockerDependencyStatus { @@ -38,15 +38,26 @@ interface ComposePsEntry { Health?: string; } -const WHISPER_SERVICE = 'whisper-server'; -const WHISPER_PROFILE = 'voice'; - -function withWhisperProfile(args: string[]): string[] { - return ['--profile', WHISPER_PROFILE, ...args]; +interface ComposeDiscovery { + profileArgs: string[]; + services: string[]; } +interface DockerDependencyDescriptor { + id: DockerDependencyId; + name: string; + service: string; + configured: boolean; +} + +const COMPOSE_FILE = 'docker-compose.yml'; +const FLYNN_SERVICE = 'flynn'; +const WHISPER_SERVICE = 'whisper-server'; +const WHISPER_DEPENDENCY_ID = 'whisper'; +const BRAVE_SEARCH_SERVICE = 'brave-search'; + function runCompose(args: string[], timeout: number): Promise { - return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], { + return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], { timeout, maxBuffer: 4 * 1024 * 1024, }) as Promise; @@ -84,6 +95,26 @@ function parseServiceList(output: string): string[] { .filter((line) => line.length > 0); } +function unique(items: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const item of items) { + const key = item.trim(); + if (!key || seen.has(key)) {continue;} + seen.add(key); + out.push(key); + } + return out; +} + +function buildProfileArgs(profiles: string[]): string[] { + return unique(profiles).flatMap((profile) => ['--profile', profile]); +} + +function withProfiles(profileArgs: string[], args: string[]): string[] { + return [...profileArgs, ...args]; +} + function parseComposePsOutput(output: string): ComposePsEntry[] { const trimmed = output.trim(); if (!trimmed) {return [];} @@ -175,87 +206,166 @@ function isWhisperConfigured(config: Config): boolean { return isLocalWhisperEndpoint(endpoint); } +function isBraveSearchConfigured(config: Config): boolean { + if (config.web_search.provider !== 'brave') {return false;} + const apiKey = config.web_search.api_key; + return typeof apiKey === 'string' && apiKey.trim().length > 0; +} + +function toDependencyId(service: string): DockerDependencyId { + if (service === WHISPER_SERVICE) { + return WHISPER_DEPENDENCY_ID; + } + return service; +} + +function toDependencyName(service: string): string { + if (service === WHISPER_SERVICE) { + return 'Whisper (whisper.cpp)'; + } + return service + .split(/[-_]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function describeDependency(service: string, config: Config): DockerDependencyDescriptor { + if (service === WHISPER_SERVICE) { + return { + id: WHISPER_DEPENDENCY_ID, + name: 'Whisper (whisper.cpp)', + service, + configured: isWhisperConfigured(config), + }; + } + if (service === BRAVE_SEARCH_SERVICE) { + return { + id: BRAVE_SEARCH_SERVICE, + name: 'Brave Search', + service, + configured: isBraveSearchConfigured(config), + }; + } + return { + id: toDependencyId(service), + name: toDependencyName(service), + service, + configured: true, + }; +} + +function unavailableStatus(error: unknown): DockerDependencyStatus { + return { + id: 'compose', + name: 'Docker Compose', + service: COMPOSE_FILE, + configured: false, + state: 'unavailable', + health: 'none', + statusText: 'unavailable', + containerName: null, + availableActions: [], + error: normalizeError(error), + }; +} + +async function discoverCompose(runner: DockerComposeRunner): Promise { + let profiles: string[] = []; + try { + const response = await runner(['config', '--profiles']); + profiles = parseServiceList(response.stdout); + } catch { + profiles = []; + } + + const profileArgs = buildProfileArgs(profiles); + const response = await runner(withProfiles(profileArgs, ['config', '--services'])); + const configuredServices = parseServiceList(response.stdout) + .filter((service) => service !== FLYNN_SERVICE); + + // Compose `ps --all` can surface running services even when profile discovery is unavailable. + let runtimeServices: string[] = []; + try { + const psResponse = await runner(withProfiles(profileArgs, ['ps', '--all', '--format', 'json'])); + runtimeServices = parseComposePsOutput(psResponse.stdout) + .map((entry) => String(entry.Service ?? '').trim()) + .filter((service) => service.length > 0 && service !== FLYNN_SERVICE); + } catch { + runtimeServices = []; + } + + return { + profileArgs, + services: unique([...configuredServices, ...runtimeServices]), + }; +} + export async function listDockerDependencyStatuses( config: Config, runner: DockerComposeRunner = defaultRunner, ): Promise { - const whisperStatus: DockerDependencyStatus = { - id: 'whisper', - name: 'Whisper (whisper.cpp)', - service: WHISPER_SERVICE, - configured: isWhisperConfigured(config), - state: 'unknown', - health: 'unknown', - statusText: 'unknown', - containerName: null, - availableActions: [], - }; - - let services: string[]; + let discovery: ComposeDiscovery; try { - const response = await runner(withWhisperProfile(['config', '--services'])); - services = parseServiceList(response.stdout); + discovery = await discoverCompose(runner); } catch (error) { - return [{ - ...whisperStatus, - state: 'unavailable', - statusText: 'unavailable', - availableActions: [], - error: normalizeError(error), - }]; + return [unavailableStatus(error)]; } - if (!services.includes(WHISPER_SERVICE)) { - return [{ - ...whisperStatus, - state: 'not-found', - health: 'none', - statusText: 'service not defined in docker-compose.yml', - availableActions: [], - }]; + if (discovery.services.length === 0) { + return []; } - try { - const response = await runner(withWhisperProfile(['ps', WHISPER_SERVICE, '--format', 'json'])); - const rows = parseComposePsOutput(response.stdout) - .filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service); - - if (rows.length === 0) { - return [{ - ...whisperStatus, - state: 'not-created', - health: 'none', - statusText: 'defined, not started', - availableActions: computeAvailableActions('not-created'), - }]; - } - - const row = rows[0]; - const state = normalizeState(row.State); - const health = String(row.Health ?? '').trim().toLowerCase() || 'none'; - const statusField = String(row.Status ?? ''); - return [{ - ...whisperStatus, - state, - health, - statusText: buildStatusText(state, health, statusField), - containerName: row.Name?.trim() || null, - availableActions: computeAvailableActions(state), - }]; - } catch (error) { - return [{ - ...whisperStatus, + return Promise.all(discovery.services.map(async (service): Promise => { + const descriptor = describeDependency(service, config); + const baseStatus: DockerDependencyStatus = { + id: descriptor.id, + name: descriptor.name, + service: descriptor.service, + configured: descriptor.configured, + state: 'unknown', + health: 'unknown', statusText: 'unknown', - availableActions: computeAvailableActions('unknown'), - error: normalizeError(error), - }]; - } -} + containerName: null, + availableActions: [], + }; -function ensureValidDependency(id: string): asserts id is DockerDependencyId { - if (id !== 'whisper') { - throw new Error(`Unsupported dependency: ${id}`); - } + try { + const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json'])); + const rows = parseComposePsOutput(response.stdout) + .filter((entry) => (entry.Service ?? '') === service || !entry.Service); + + if (rows.length === 0) { + return { + ...baseStatus, + state: 'not-created', + health: 'none', + statusText: 'defined, not started', + availableActions: computeAvailableActions('not-created'), + }; + } + + const row = rows[0]; + const state = normalizeState(row.State); + const health = String(row.Health ?? '').trim().toLowerCase() || 'none'; + const statusField = String(row.Status ?? ''); + return { + ...baseStatus, + state, + health, + statusText: buildStatusText(state, health, statusField), + containerName: row.Name?.trim() || null, + availableActions: computeAvailableActions(state), + }; + } catch (error) { + return { + ...baseStatus, + statusText: 'unknown', + availableActions: computeAvailableActions('unknown'), + error: normalizeError(error), + }; + } + })); } function ensureValidAction(action: string): asserts action is DockerDependencyAction { @@ -264,12 +374,18 @@ function ensureValidAction(action: string): asserts action is DockerDependencyAc } } -async function ensureWhisperServiceDefined(runner: DockerComposeRunner): Promise { - const response = await runner(withWhisperProfile(['config', '--services'])); - const services = parseServiceList(response.stdout); - if (!services.includes(WHISPER_SERVICE)) { - throw new Error('whisper-server service is not defined in docker-compose.yml'); +async function resolveDiscoveredDependency( + config: Config, + dependency: string, + runner: DockerComposeRunner, +): Promise<{ discovery: ComposeDiscovery; descriptor: DockerDependencyDescriptor }> { + const discovery = await discoverCompose(runner); + const descriptors = discovery.services.map((service) => describeDependency(service, config)); + const descriptor = descriptors.find((entry) => entry.id === dependency || entry.service === dependency); + if (!descriptor) { + throw new Error(`Unsupported dependency: ${dependency}`); } + return { discovery, descriptor }; } export async function controlDockerDependency( @@ -278,43 +394,44 @@ export async function controlDockerDependency( action: string, runner: DockerComposeRunner = defaultControlRunner, ): Promise { - ensureValidDependency(dependency); ensureValidAction(action); - await ensureWhisperServiceDefined(runner); + const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner); + const service = descriptor.service; let message: string | undefined; if (action === 'start') { - await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); - message = 'Started whisper-server container.'; + await runner(withProfiles(discovery.profileArgs, ['up', '-d', service])); + message = `Started ${service} container.`; } else if (action === 'restart') { try { - await runner(withWhisperProfile(['restart', WHISPER_SERVICE])); - message = 'Restarted whisper-server container.'; + await runner(withProfiles(discovery.profileArgs, ['restart', service])); + message = `Restarted ${service} container.`; } catch (error) { const detail = normalizeError(error).toLowerCase(); if (detail.includes('no containers to restart')) { - await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); - message = 'Whisper container was not running; started it.'; + await runner(withProfiles(discovery.profileArgs, ['up', '-d', service])); + message = `${service} container was not running; started it.`; } else { throw error; } } } else if (action === 'stop') { - await runner(withWhisperProfile(['stop', WHISPER_SERVICE])); - message = 'Stopped whisper-server container.'; + await runner(withProfiles(discovery.profileArgs, ['stop', service])); + message = `Stopped ${service} container.`; } else if (action === 'update') { - await runner(withWhisperProfile(['pull', WHISPER_SERVICE])); - await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE])); - message = 'Pulled latest whisper image and reconciled container.'; + await runner(withProfiles(discovery.profileArgs, ['pull', service])); + await runner(withProfiles(discovery.profileArgs, ['up', '-d', service])); + message = `Pulled latest ${service} image and reconciled container.`; } - const status = (await listDockerDependencyStatuses(config, runner))[0]; + const status = (await listDockerDependencyStatuses(config, runner)) + .find((entry) => entry.id === descriptor.id || entry.service === descriptor.service); if (!status) { throw new Error('Failed to load docker dependency status after action.'); } return { - dependency, + dependency: descriptor.id, action, status, message, diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts index a1389c2..076d900 100644 --- a/src/gateway/ui/pages/dashboard.test.ts +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -137,6 +137,17 @@ function createMockClient() { containerName: 'flynn-whisper-server-1', availableActions: ['restart', 'stop', 'update'], }, + { + id: 'brave-search', + name: 'Brave Search', + service: 'brave-search', + configured: true, + state: 'running', + health: 'healthy', + statusText: 'Up 2 minutes', + containerName: 'brave-search', + availableActions: ['restart', 'stop', 'update'], + }, ], calls: [] as Array<{ method: string; params?: Record }>, }; @@ -275,7 +286,7 @@ function createMockClient() { dependency.state = 'running'; dependency.health = 'healthy'; dependency.statusText = 'running (healthy)'; - dependency.containerName = 'whisper-server'; + dependency.containerName = String(dependency.service ?? dependency.id ?? ''); dependency.availableActions = ['restart', 'stop', 'update']; } else if (action === 'stop') { dependency.state = 'stopped'; @@ -533,7 +544,9 @@ describe('DashboardPage assistant controls', () => { const card = container.querySelector('#ops-docker-dependencies'); expect(card).toBeTruthy(); expect(String(card.textContent ?? '')).toContain('Whisper (whisper.cpp)'); + expect(String(card.textContent ?? '')).toContain('Brave Search'); expect(String(card.textContent ?? '')).toContain('whisper-server'); + expect(String(card.textContent ?? '')).toContain('brave-search'); expect(String(card.textContent ?? '')).toContain('Up 2 minutes (healthy)'); }); @@ -562,12 +575,19 @@ describe('DashboardPage assistant controls', () => { updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); await flush(); + const braveRestartBtn = container.querySelector('#ops-docker-dependencies .docker-dependency-action-btn[data-dependency-id="brave-search"][data-action="restart"]'); + expect(braveRestartBtn).toBeTruthy(); + braveRestartBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + const dependencyCalls = state.calls.filter((entry) => entry.method === 'system.dockerDependencyControl'); - expect(dependencyCalls).toHaveLength(4); + expect(dependencyCalls).toHaveLength(5); expect(dependencyCalls[0].params).toEqual({ dependency: 'whisper', action: 'restart' }); expect(dependencyCalls[1].params).toEqual({ dependency: 'whisper', action: 'stop' }); expect(dependencyCalls[2].params).toEqual({ dependency: 'whisper', action: 'start' }); expect(dependencyCalls[3].params).toEqual({ dependency: 'whisper', action: 'update' }); + expect(dependencyCalls[4].params).toEqual({ dependency: 'brave-search', action: 'restart' }); expect(state.dockerDependencies.find((entry) => entry.id === 'whisper')?.state).toBe('running'); + expect(state.dockerDependencies.find((entry) => entry.id === 'brave-search')?.state).toBe('running'); }); });