feat: discover docker compose deps for dashboard

This commit is contained in:
William Valentin
2026-02-22 20:22:37 -08:00
parent ba6abfb078
commit 58eee60023
5 changed files with 460 additions and 187 deletions
+14 -2
View File
@@ -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
+15 -1
View File
@@ -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%)",
+196 -86
View File
@@ -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'],
});
it('reports whisper as defined but not started when no container exists yet', async () => {
expect(seenCalls[0]).toEqual(['config', '--profiles']);
expect(seenCalls[1]).toEqual([...profileArgs(), 'config', '--services']);
});
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');
});
});
+190 -73
View File
@@ -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<DockerComposeResult> {
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
timeout,
maxBuffer: 4 * 1024 * 1024,
}) as Promise<DockerComposeResult>;
@@ -84,6 +95,26 @@ function parseServiceList(output: string): string[] {
.filter((line) => line.length > 0);
}
function unique(items: string[]): string[] {
const seen = new Set<string>();
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,15 +206,123 @@ 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<ComposeDiscovery> {
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<DockerDependencyStatus[]> {
const whisperStatus: DockerDependencyStatus = {
id: 'whisper',
name: 'Whisper (whisper.cpp)',
service: WHISPER_SERVICE,
configured: isWhisperConfigured(config),
let discovery: ComposeDiscovery;
try {
discovery = await discoverCompose(runner);
} catch (error) {
return [unavailableStatus(error)];
}
if (discovery.services.length === 0) {
return [];
}
return Promise.all(discovery.services.map(async (service): Promise<DockerDependencyStatus> => {
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',
@@ -191,71 +330,42 @@ export async function listDockerDependencyStatuses(
availableActions: [],
};
let services: string[];
try {
const response = await runner(withWhisperProfile(['config', '--services']));
services = parseServiceList(response.stdout);
} catch (error) {
return [{
...whisperStatus,
state: 'unavailable',
statusText: 'unavailable',
availableActions: [],
error: normalizeError(error),
}];
}
if (!services.includes(WHISPER_SERVICE)) {
return [{
...whisperStatus,
state: 'not-found',
health: 'none',
statusText: 'service not defined in docker-compose.yml',
availableActions: [],
}];
}
try {
const response = await runner(withWhisperProfile(['ps', WHISPER_SERVICE, '--format', 'json']));
const response = await runner(withProfiles(discovery.profileArgs, ['ps', service, '--format', 'json']));
const rows = parseComposePsOutput(response.stdout)
.filter((entry) => (entry.Service ?? '') === WHISPER_SERVICE || !entry.Service);
.filter((entry) => (entry.Service ?? '') === service || !entry.Service);
if (rows.length === 0) {
return [{
...whisperStatus,
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 [{
...whisperStatus,
return {
...baseStatus,
state,
health,
statusText: buildStatusText(state, health, statusField),
containerName: row.Name?.trim() || null,
availableActions: computeAvailableActions(state),
}];
};
} catch (error) {
return [{
...whisperStatus,
return {
...baseStatus,
statusText: 'unknown',
availableActions: computeAvailableActions('unknown'),
error: normalizeError(error),
}];
}
}
function ensureValidDependency(id: string): asserts id is DockerDependencyId {
if (id !== 'whisper') {
throw new Error(`Unsupported dependency: ${id}`);
};
}
}));
}
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<void> {
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<DockerDependencyControlResult> {
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,
+22 -2
View File
@@ -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<string, unknown> }>,
};
@@ -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');
});
});