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 = string; export type DockerDependencyAction = 'start' | 'restart' | 'stop' | 'update'; export interface DockerDependencyStatus { id: DockerDependencyId; name: string; service: string; configured: boolean; state: string; 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; interface ComposePsEntry { Name?: string; Service?: string; State?: string; Status?: string; Health?: string; } 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', COMPOSE_FILE, ...args], { timeout, maxBuffer: 4 * 1024 * 1024, }) as Promise; } function defaultRunner(args: string[]): Promise { return runCompose(args, 10_000); } function defaultControlRunner(args: string[]): Promise { 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 }; 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 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 [];} 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 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 { 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); } 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 { 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 => { 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', containerName: null, availableActions: [], }; 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 { if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') { throw new Error(`Unsupported action: ${action}`); } } 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( config: Config, dependency: string, action: string, runner: DockerComposeRunner = defaultControlRunner, ): Promise { ensureValidAction(action); const { discovery, descriptor } = await resolveDiscoveredDependency(config, dependency, runner); const service = descriptor.service; let message: string | undefined; if (action === 'start') { await runner(withProfiles(discovery.profileArgs, ['up', '-d', service])); message = `Started ${service} container.`; } else if (action === 'restart') { try { 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(withProfiles(discovery.profileArgs, ['up', '-d', service])); message = `${service} container was not running; started it.`; } else { throw error; } } } else if (action === 'stop') { await runner(withProfiles(discovery.profileArgs, ['stop', service])); message = `Stopped ${service} container.`; } else if (action === 'update') { 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)) .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: descriptor.id, action, status, message, }; }