Add whisper docker dependency status to gateway dashboard

This commit is contained in:
William Valentin
2026-02-22 19:20:09 -08:00
parent d4a13e3c71
commit 8fcbb5f521
11 changed files with 559 additions and 24 deletions
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import type { Config } from '../../config/index.js';
import { listDockerDependencyStatuses } from './dockerDependencies.js';
function createConfig(endpoint: string, enabled = true): Config {
return {
audio: {
enabled,
provider: {
type: 'custom',
endpoint,
},
},
} as unknown as Config;
}
describe('listDockerDependencyStatuses', () => {
it('reports whisper as running when compose ps shows active container', async () => {
const runner = async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
}
if (args[0] === 'ps') {
return {
stdout: JSON.stringify([{
Name: 'flynn-whisper-server-1',
Service: 'whisper-server',
State: 'running',
Health: 'healthy',
Status: 'Up 4 minutes (healthy)',
}]),
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({
id: 'whisper',
configured: true,
state: 'running',
health: 'healthy',
statusText: 'Up 4 minutes (healthy)',
containerName: 'flynn-whisper-server-1',
});
});
it('reports whisper as defined but not started when no container exists yet', async () => {
const runner = async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: 'flynn\nwhisper-server\n', stderr: '' };
}
if (args[0] === 'ps') {
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,
});
});
it('reports whisper service as missing when compose file does not define it', async () => {
const runner = async (args: string[]) => {
if (args[0] === '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',
});
});
it('returns unavailable status when docker compose command 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].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[0] === 'config') {
return { stdout: 'whisper-server\n', stderr: '' };
}
if (args[0] === 'ps') {
return { stdout: '[]', stderr: '' };
}
throw new Error(`Unexpected args: ${args.join(' ')}`);
};
const statuses = await listDockerDependencyStatuses(
createConfig('https://api.openai.com/v1/audio/transcriptions'),
runner,
);
expect(statuses[0]?.configured).toBe(false);
});
});
+208
View File
@@ -0,0 +1,208 @@
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import type { Config } from '../../config/index.js';
const execFile = promisify(execFileCb);
export type DockerDependencyId = 'whisper';
export interface DockerDependencyStatus {
id: DockerDependencyId;
name: string;
service: string;
configured: boolean;
state: string;
health: string;
statusText: string;
containerName: string | null;
error?: string;
}
type DockerComposeResult = { stdout: string; stderr: string };
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
interface ComposePsEntry {
Name?: string;
Service?: string;
State?: string;
Status?: string;
Health?: string;
}
const WHISPER_SERVICE = 'whisper-server';
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
}) as Promise<DockerComposeResult>;
}
function normalizeError(error: unknown): string {
if (error && typeof error === 'object') {
const maybe = error as { stderr?: string; stdout?: string; message?: string };
const stderr = maybe.stderr?.trim();
if (stderr) {return stderr;}
const stdout = maybe.stdout?.trim();
if (stdout) {return stdout;}
if (typeof maybe.message === 'string' && maybe.message.trim().length > 0) {
return maybe.message.trim();
}
}
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return String(error);
}
function parseServiceList(output: string): string[] {
return output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function parseComposePsOutput(output: string): ComposePsEntry[] {
const trimmed = output.trim();
if (!trimmed) {return [];}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is ComposePsEntry => Boolean(entry && typeof entry === 'object'));
}
if (parsed && typeof parsed === 'object') {
return [parsed as ComposePsEntry];
}
} catch {
// Some compose versions emit newline-delimited JSON objects instead of an array.
}
const entries: ComposePsEntry[] = [];
for (const line of trimmed.split('\n')) {
const item = line.trim();
if (!item) {continue;}
try {
const parsed = JSON.parse(item) as unknown;
if (parsed && typeof parsed === 'object') {
entries.push(parsed as ComposePsEntry);
}
} catch {
// Ignore non-JSON lines.
}
}
return entries;
}
function normalizeState(rawState: string | undefined): string {
const state = String(rawState ?? '').trim().toLowerCase();
if (!state) {return 'unknown';}
if (state === 'running') {return 'running';}
if (state === 'exited' || state === 'dead' || state === 'stopped') {return 'stopped';}
if (state === 'created' || state === 'restarting' || state === 'paused') {return state;}
return state;
}
function buildStatusText(state: string, health: string, statusField: string): string {
if (statusField.trim().length > 0) {
return statusField.trim();
}
if (state === 'running' && health && health !== 'none' && health !== 'unknown') {
return `running (${health})`;
}
return state || 'unknown';
}
function isLocalWhisperEndpoint(endpoint: string): boolean {
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
return false;
}
const isLocalHost = parsed.hostname === 'localhost'
|| parsed.hostname === '127.0.0.1'
|| parsed.hostname === '::1'
|| parsed.hostname === '0.0.0.0';
if (!isLocalHost) {return false;}
return parsed.pathname.includes('/audio/transcriptions');
}
function isWhisperConfigured(config: Config): boolean {
if (!config.audio.enabled) {return false;}
const endpoint = config.audio.provider?.endpoint;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) {
return false;
}
return isLocalWhisperEndpoint(endpoint);
}
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),
state: 'unknown',
health: 'unknown',
statusText: 'unknown',
containerName: null,
};
let services: string[];
try {
const response = await runner(['config', '--services']);
services = parseServiceList(response.stdout);
} catch (error) {
return [{
...whisperStatus,
statusText: 'unavailable',
error: normalizeError(error),
}];
}
if (!services.includes(WHISPER_SERVICE)) {
return [{
...whisperStatus,
state: 'not-found',
health: 'none',
statusText: 'service not defined in docker-compose.yml',
}];
}
try {
const response = await runner(['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',
}];
}
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,
}];
} catch (error) {
return [{
...whisperStatus,
statusText: 'unknown',
error: normalizeError(error),
}];
}
}
+31
View File
@@ -11,6 +11,7 @@ import { createCanvasHandlers } from './canvas.js';
import { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js';
import type { DockerDependencyStatus } from './dockerDependencies.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.js';
import { CanvasStore } from '../canvas-store.js';
@@ -283,6 +284,36 @@ describe('system handlers', () => {
expect(getPath(result.result, 'updatedModels')).toEqual(['llama3.2', 'nomic-embed-text']);
});
it('system.dockerDependencies returns empty list when callback is not provided', async () => {
const req: GatewayRequest = { id: 42, method: 'system.dockerDependencies' };
const result = await handlers['system.dockerDependencies'](req) as GatewayResponse;
expect(getPath(result.result, 'dependencies')).toEqual([]);
});
it('system.dockerDependencies returns dependency statuses from callback', async () => {
const getDockerDependencies = vi.fn(async (): Promise<DockerDependencyStatus[]> => ([
{
id: 'whisper',
name: 'Whisper (whisper.cpp)',
service: 'whisper-server',
configured: true,
state: 'running',
health: 'healthy',
statusText: 'Up 10 minutes (healthy)',
containerName: 'flynn-whisper-server-1',
},
]));
const handlers = createSystemHandlers({
...deps,
getDockerDependencies,
});
const req: GatewayRequest = { id: 43, method: 'system.dockerDependencies' };
const result = await handlers['system.dockerDependencies'](req) as GatewayResponse;
expect(getDockerDependencies).toHaveBeenCalledTimes(1);
expect(getPath(result.result, 'dependencies', '0', 'id')).toBe('whisper');
expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running');
});
it('system.presence returns empty result when getPresence is not provided', async () => {
const req: GatewayRequest = { id: 4, method: 'system.presence' };
const result = await handlers['system.presence'](req) as GatewayResponse;
+15
View File
@@ -5,6 +5,7 @@ import type { ServiceInfo } from './services.js';
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
import type { LocalBackendAction, LocalBackendControlResult, LocalBackendStatus } from './localBackends.js';
import type { DockerDependencyStatus } from './dockerDependencies.js';
/** Per-session token usage report returned by system.tokenUsage. */
export interface TokenUsageEntry {
@@ -108,6 +109,8 @@ export interface SystemHandlerDeps {
getLocalBackends?: () => Promise<LocalBackendStatus[]> | LocalBackendStatus[];
/** Optional callback to control local backend daemons. */
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
/** Optional callback to retrieve docker-compose dependency statuses. */
getDockerDependencies?: () => Promise<DockerDependencyStatus[]> | DockerDependencyStatus[];
}
function normalizeErrorMessage(error: unknown): string {
@@ -335,5 +338,17 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
return makeError(request.id, ErrorCode.InternalError, `Local backend control failed: ${normalizeErrorMessage(error)}`);
}
},
'system.dockerDependencies': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.getDockerDependencies) {
return makeResponse(request.id, { dependencies: [] });
}
try {
const dependencies = await deps.getDockerDependencies();
return makeResponse(request.id, { dependencies });
} catch (error) {
return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`);
}
},
};
}