Add whisper docker dependency controls to dashboard
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Config } from '../../config/index.js';
|
||||
import { listDockerDependencyStatuses } from './dockerDependencies.js';
|
||||
import { listDockerDependencyStatuses, controlDockerDependency } from './dockerDependencies.js';
|
||||
|
||||
function createConfig(endpoint: string, enabled = true): Config {
|
||||
return {
|
||||
@@ -49,6 +49,7 @@ describe('listDockerDependencyStatuses', () => {
|
||||
health: 'healthy',
|
||||
statusText: 'Up 4 minutes (healthy)',
|
||||
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']);
|
||||
@@ -75,6 +76,7 @@ describe('listDockerDependencyStatuses', () => {
|
||||
statusText: 'defined, not started',
|
||||
health: 'none',
|
||||
configured: true,
|
||||
availableActions: ['start', 'restart', 'update'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +97,7 @@ describe('listDockerDependencyStatuses', () => {
|
||||
state: 'not-found',
|
||||
statusText: 'service not defined in docker-compose.yml',
|
||||
health: 'none',
|
||||
availableActions: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +111,8 @@ describe('listDockerDependencyStatuses', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -129,3 +134,82 @@ describe('listDockerDependencyStatuses', () => {
|
||||
expect(statuses[0]?.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controlDockerDependency', () => {
|
||||
it('starts whisper via compose up and returns refreshed status', 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.includes('up')) {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'whisper-server',
|
||||
Service: 'whisper-server',
|
||||
State: 'running',
|
||||
Health: 'healthy',
|
||||
Status: 'Up 2 seconds (healthy)',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const result = await controlDockerDependency(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
'whisper',
|
||||
'start',
|
||||
runner,
|
||||
);
|
||||
expect(result.action).toBe('start');
|
||||
expect(result.status.state).toBe('running');
|
||||
expect(result.status.availableActions).toEqual(['restart', 'stop', 'update']);
|
||||
expect(calls).toContainEqual(['--profile', 'voice', 'up', '-d', 'whisper-server']);
|
||||
});
|
||||
|
||||
it('updates whisper 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.includes('pull')) {
|
||||
return { stdout: 'Pulled', stderr: '' };
|
||||
}
|
||||
if (args.includes('up')) {
|
||||
return { stdout: 'Started', stderr: '' };
|
||||
}
|
||||
if (args.includes('ps')) {
|
||||
return {
|
||||
stdout: JSON.stringify([{
|
||||
Name: 'whisper-server',
|
||||
Service: 'whisper-server',
|
||||
State: 'running',
|
||||
Health: 'healthy',
|
||||
Status: 'Up 1 minute (healthy)',
|
||||
}]),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected args: ${args.join(' ')}`);
|
||||
};
|
||||
|
||||
const result = await controlDockerDependency(
|
||||
createConfig('http://localhost:18801/v1/audio/transcriptions'),
|
||||
'whisper',
|
||||
'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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Config } from '../../config/index.js';
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export type DockerDependencyId = 'whisper';
|
||||
export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update';
|
||||
|
||||
export interface DockerDependencyStatus {
|
||||
id: DockerDependencyId;
|
||||
@@ -15,9 +16,17 @@ export interface DockerDependencyStatus {
|
||||
health: string;
|
||||
statusText: string;
|
||||
containerName: string | null;
|
||||
availableActions: DockerDependencyAction[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DockerDependencyControlResult {
|
||||
dependency: DockerDependencyId;
|
||||
action: DockerDependencyAction;
|
||||
status: DockerDependencyStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type DockerComposeResult = { stdout: string; stderr: string };
|
||||
type DockerComposeRunner = (args: string[]) => Promise<DockerComposeResult>;
|
||||
|
||||
@@ -36,13 +45,21 @@ function withWhisperProfile(args: string[]): string[] {
|
||||
return ['--profile', WHISPER_PROFILE, ...args];
|
||||
}
|
||||
|
||||
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
|
||||
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
|
||||
return execFile('docker', ['compose', '-f', 'docker-compose.yml', ...args], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}) as Promise<DockerComposeResult>;
|
||||
}
|
||||
|
||||
function defaultRunner(args: string[]): Promise<DockerComposeResult> {
|
||||
return runCompose(args, 10_000);
|
||||
}
|
||||
|
||||
function defaultControlRunner(args: string[]): Promise<DockerComposeResult> {
|
||||
return runCompose(args, 15 * 60_000);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const maybe = error as { stderr?: string; stdout?: string; message?: string };
|
||||
@@ -118,6 +135,22 @@ function buildStatusText(state: string, health: string, statusField: string): st
|
||||
return state || 'unknown';
|
||||
}
|
||||
|
||||
function computeAvailableActions(state: string): DockerDependencyAction[] {
|
||||
if (state === 'not-found' || state === 'unavailable') {
|
||||
return [];
|
||||
}
|
||||
if (state === 'running') {
|
||||
return ['restart', 'stop', 'update'];
|
||||
}
|
||||
if (state === 'stopped' || state === 'not-created' || state === 'created') {
|
||||
return ['start', 'restart', 'update'];
|
||||
}
|
||||
if (state === 'restarting' || state === 'paused') {
|
||||
return ['restart', 'stop', 'update'];
|
||||
}
|
||||
return ['start', 'restart', 'stop', 'update'];
|
||||
}
|
||||
|
||||
function isLocalWhisperEndpoint(endpoint: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -155,6 +188,7 @@ export async function listDockerDependencyStatuses(
|
||||
health: 'unknown',
|
||||
statusText: 'unknown',
|
||||
containerName: null,
|
||||
availableActions: [],
|
||||
};
|
||||
|
||||
let services: string[];
|
||||
@@ -164,7 +198,9 @@ export async function listDockerDependencyStatuses(
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
state: 'unavailable',
|
||||
statusText: 'unavailable',
|
||||
availableActions: [],
|
||||
error: normalizeError(error),
|
||||
}];
|
||||
}
|
||||
@@ -175,6 +211,7 @@ export async function listDockerDependencyStatuses(
|
||||
state: 'not-found',
|
||||
health: 'none',
|
||||
statusText: 'service not defined in docker-compose.yml',
|
||||
availableActions: [],
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -189,6 +226,7 @@ export async function listDockerDependencyStatuses(
|
||||
state: 'not-created',
|
||||
health: 'none',
|
||||
statusText: 'defined, not started',
|
||||
availableActions: computeAvailableActions('not-created'),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -202,12 +240,83 @@ export async function listDockerDependencyStatuses(
|
||||
health,
|
||||
statusText: buildStatusText(state, health, statusField),
|
||||
containerName: row.Name?.trim() || null,
|
||||
availableActions: computeAvailableActions(state),
|
||||
}];
|
||||
} catch (error) {
|
||||
return [{
|
||||
...whisperStatus,
|
||||
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 {
|
||||
if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') {
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
export async function controlDockerDependency(
|
||||
config: Config,
|
||||
dependency: string,
|
||||
action: string,
|
||||
runner: DockerComposeRunner = defaultControlRunner,
|
||||
): Promise<DockerDependencyControlResult> {
|
||||
ensureValidDependency(dependency);
|
||||
ensureValidAction(action);
|
||||
await ensureWhisperServiceDefined(runner);
|
||||
|
||||
let message: string | undefined;
|
||||
if (action === 'start') {
|
||||
await runner(withWhisperProfile(['up', '-d', WHISPER_SERVICE]));
|
||||
message = 'Started whisper-server container.';
|
||||
} else if (action === 'restart') {
|
||||
try {
|
||||
await runner(withWhisperProfile(['restart', WHISPER_SERVICE]));
|
||||
message = 'Restarted whisper-server 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.';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (action === 'stop') {
|
||||
await runner(withWhisperProfile(['stop', WHISPER_SERVICE]));
|
||||
message = 'Stopped whisper-server 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.';
|
||||
}
|
||||
|
||||
const status = (await listDockerDependencyStatuses(config, runner))[0];
|
||||
if (!status) {
|
||||
throw new Error('Failed to load docker dependency status after action.');
|
||||
}
|
||||
|
||||
return {
|
||||
dependency,
|
||||
action,
|
||||
status,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +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 type { DockerDependencyStatus, DockerDependencyControlResult } from './dockerDependencies.js';
|
||||
import { PairingManager } from '../../channels/pairing.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { CanvasStore } from '../canvas-store.js';
|
||||
@@ -301,6 +301,7 @@ describe('system handlers', () => {
|
||||
health: 'healthy',
|
||||
statusText: 'Up 10 minutes (healthy)',
|
||||
containerName: 'flynn-whisper-server-1',
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
},
|
||||
]));
|
||||
const handlers = createSystemHandlers({
|
||||
@@ -314,6 +315,66 @@ describe('system handlers', () => {
|
||||
expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running');
|
||||
});
|
||||
|
||||
it('system.dockerDependencyControl validates required params', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
controlDockerDependency: vi.fn(),
|
||||
});
|
||||
|
||||
const missingDependency = await handlers['system.dockerDependencyControl']({
|
||||
id: 44,
|
||||
method: 'system.dockerDependencyControl',
|
||||
params: { action: 'restart' },
|
||||
}) as GatewayError;
|
||||
expect(missingDependency.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
|
||||
const missingAction = await handlers['system.dockerDependencyControl']({
|
||||
id: 45,
|
||||
method: 'system.dockerDependencyControl',
|
||||
params: { dependency: 'whisper' },
|
||||
}) as GatewayError;
|
||||
expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
|
||||
const badAction = await handlers['system.dockerDependencyControl']({
|
||||
id: 46,
|
||||
method: 'system.dockerDependencyControl',
|
||||
params: { dependency: 'whisper', action: 'reload' },
|
||||
}) as GatewayError;
|
||||
expect(badAction.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
});
|
||||
|
||||
it('system.dockerDependencyControl forwards action to callback', async () => {
|
||||
const controlDockerDependency = vi.fn(async (): Promise<DockerDependencyControlResult> => ({
|
||||
dependency: 'whisper' as const,
|
||||
action: 'restart' as const,
|
||||
status: {
|
||||
id: 'whisper' as const,
|
||||
name: 'Whisper (whisper.cpp)',
|
||||
service: 'whisper-server',
|
||||
configured: true,
|
||||
state: 'running',
|
||||
health: 'healthy',
|
||||
statusText: 'running (healthy)',
|
||||
containerName: 'whisper-server',
|
||||
availableActions: ['restart', 'stop', 'update'],
|
||||
},
|
||||
message: 'Restarted whisper-server container.',
|
||||
}));
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
controlDockerDependency,
|
||||
});
|
||||
const req: GatewayRequest = {
|
||||
id: 47,
|
||||
method: 'system.dockerDependencyControl',
|
||||
params: { dependency: 'whisper', action: 'restart' },
|
||||
};
|
||||
const result = await handlers['system.dockerDependencyControl'](req) as GatewayResponse;
|
||||
expect(controlDockerDependency).toHaveBeenCalledWith('whisper', 'restart');
|
||||
expect(getPath(result.result, 'status', 'state')).toBe('running');
|
||||
expect(getPath(result.result, 'action')).toBe('restart');
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,11 @@ 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';
|
||||
import type {
|
||||
DockerDependencyAction,
|
||||
DockerDependencyControlResult,
|
||||
DockerDependencyStatus,
|
||||
} from './dockerDependencies.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -111,6 +115,8 @@ export interface SystemHandlerDeps {
|
||||
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
||||
/** Optional callback to retrieve docker-compose dependency statuses. */
|
||||
getDockerDependencies?: () => Promise<DockerDependencyStatus[]> | DockerDependencyStatus[];
|
||||
/** Optional callback to control docker-compose dependencies. */
|
||||
controlDockerDependency?: (dependency: string, action: string) => Promise<DockerDependencyControlResult>;
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
@@ -350,5 +356,28 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Failed to load docker dependencies: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
'system.dockerDependencyControl': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.controlDockerDependency) {
|
||||
return makeError(request.id, ErrorCode.InternalError, 'Docker dependency control is not available in this environment');
|
||||
}
|
||||
const params = request.params as { dependency?: string; action?: DockerDependencyAction } | undefined;
|
||||
if (!params?.dependency || typeof params.dependency !== 'string') {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'dependency is required');
|
||||
}
|
||||
if (!params?.action || typeof params.action !== 'string') {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'action is required');
|
||||
}
|
||||
if (!['start', 'restart', 'stop', 'update'].includes(params.action)) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop, update');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.controlDockerDependency(params.dependency, params.action);
|
||||
return makeResponse(request.id, result);
|
||||
} catch (error) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Docker dependency control failed: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user