diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 5a2ad87..9634974 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -384,7 +384,11 @@ Return status for user-level local LLM backend daemons (for example `ollama.serv #### `system.localBackendControl` -Control a local backend daemon (`start`, `restart`, `stop`). +Control a local backend daemon (`start`, `restart`, `stop`, `update`). + +- `update` semantics: + - `ollama`: pulls configured Ollama models (tiers/local providers + embedding/audio models) via `ollama pull`. + - `llamacpp`: performs a safe service restart (model file refresh remains external to Flynn). **Request:** ```json diff --git a/docs/plans/state.json b/docs/plans/state.json index cc47400..4f0a49b 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,22 @@ "updated_at": "2026-02-23", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "dashboard-local-backend-update-actions": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Extended local backend controls with explicit `update` action support. `ollama` update now pulls configured local models, while `llamacpp` uses a safe restart fallback with clear operator messaging. Updated gateway validation, dashboard action wiring, and regression coverage.", + "files_modified": [ + "src/gateway/handlers/localBackends.ts", + "src/gateway/handlers/system.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/ui/pages/dashboard.js", + "src/gateway/ui/pages/dashboard.test.ts", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts src/gateway/ui/pages/dashboard.test.ts + pnpm typecheck passing" + }, "dashboard-local-backend-daemon-controls": { "status": "completed", "date": "2026-02-23", diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index b2a4119..4dad88a 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -244,6 +244,45 @@ describe('system handlers', () => { expect(getPath(result.result, 'status', 'activeState')).toBe('active'); }); + it('system.localBackendControl accepts update action', async () => { + const controlLocalBackend = vi.fn(async (): Promise => ({ + backend: 'ollama', + action: 'update', + status: { + id: 'ollama', + provider: 'ollama', + name: 'Ollama', + unit: 'ollama.service', + configured: true, + loadState: 'loaded', + activeState: 'active', + subState: 'running', + unitFileState: 'enabled', + description: 'Ollama Service', + pid: 321, + result: 'success', + statusText: 'active (running)', + availableActions: ['restart', 'stop', 'update'], + }, + message: 'Updated 2 model(s).', + updatedModels: ['llama3.2', 'nomic-embed-text'], + })); + const handlers = createSystemHandlers({ + ...deps, + controlLocalBackend, + }); + + const req: GatewayRequest = { + id: 41, + method: 'system.localBackendControl', + params: { backend: 'ollama', action: 'update' }, + }; + const result = await handlers['system.localBackendControl'](req) as GatewayResponse; + expect(controlLocalBackend).toHaveBeenCalledWith('ollama', 'update'); + expect(getPath(result.result, 'action')).toBe('update'); + expect(getPath(result.result, 'updatedModels')).toEqual(['llama3.2', 'nomic-embed-text']); + }); + 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; diff --git a/src/gateway/handlers/localBackends.ts b/src/gateway/handlers/localBackends.ts index b2008d0..70dbaa1 100644 --- a/src/gateway/handlers/localBackends.ts +++ b/src/gateway/handlers/localBackends.ts @@ -5,7 +5,7 @@ import type { Config, ModelConfig } from '../../config/index.js'; const execFile = promisify(execFileCb); export type LocalBackendId = 'ollama' | 'llamacpp'; -export type LocalBackendAction = 'start' | 'restart' | 'stop'; +export type LocalBackendAction = 'start' | 'restart' | 'stop' | 'update'; export interface LocalBackendStatus { id: LocalBackendId; @@ -29,6 +29,8 @@ export interface LocalBackendControlResult { backend: LocalBackendId; action: LocalBackendAction; status: LocalBackendStatus; + message?: string; + updatedModels?: string[]; } type SystemctlResult = { stdout: string; stderr: string }; @@ -135,20 +137,26 @@ function isUnitMissing(errorText: string): boolean { ); } -function computeAvailableActions(activeState: string, loadState: string): LocalBackendAction[] { +function computeAvailableActions(activeState: string, loadState: string, configured: boolean): LocalBackendAction[] { if (loadState === 'not-found') { return []; } + const withUpdate = (actions: LocalBackendAction[]): LocalBackendAction[] => { + if (configured && !actions.includes('update')) { + return [...actions, 'update']; + } + return actions; + }; if (activeState === 'active') { - return ['restart', 'stop']; + return withUpdate(['restart', 'stop']); } if (activeState === 'inactive' || activeState === 'failed') { - return ['start', 'restart']; + return withUpdate(['start', 'restart']); } if (activeState === 'activating' || activeState === 'deactivating') { - return ['restart', 'stop']; + return withUpdate(['restart', 'stop']); } - return ['start', 'restart', 'stop']; + return withUpdate(['start', 'restart', 'stop']); } function buildStatusText(activeState: string, subState: string): string { @@ -206,7 +214,7 @@ async function fetchUnitStatus( statusText: '', availableActions: [], }; - status.availableActions = computeAvailableActions(status.activeState, status.loadState); + status.availableActions = computeAvailableActions(status.activeState, status.loadState, configured); status.statusText = buildStatusText(status.activeState, status.subState); return status; } catch (error) { @@ -228,14 +236,14 @@ async function fetchUnitStatus( return { ...base, statusText: 'unknown', - availableActions: ['start', 'restart', 'stop'], + availableActions: computeAvailableActions(base.activeState, base.loadState, configured), error: detail, }; } } function ensureValidAction(action: string): asserts action is LocalBackendAction { - if (action !== 'start' && action !== 'restart' && action !== 'stop') { + if (action !== 'start' && action !== 'restart' && action !== 'stop' && action !== 'update') { throw new Error(`Unsupported action: ${action}`); } } @@ -246,6 +254,89 @@ function ensureValidBackend(id: string): asserts id is LocalBackendId { } } +function collectBackendModels(config: Config, backend: LocalBackendId): string[] { + const models = new Set(); + + for (const model of collectModelConfigs(config)) { + if (model.provider === backend && typeof model.model === 'string' && model.model.trim().length > 0) { + models.add(model.model.trim()); + } + } + + if (config.memory.embedding.provider === backend) { + const embeddingModel = config.memory.embedding.model?.trim(); + if (embeddingModel) { + models.add(embeddingModel); + } + } + + const audioProvider = config.audio.provider; + if (config.audio.enabled && audioProvider?.type === backend) { + const audioModel = audioProvider.model?.trim(); + if (audioModel) { + models.add(audioModel); + } + } + + return Array.from(models); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function updateOllama(config: Config, runner: SystemctlRunner): Promise<{ + message: string; + updatedModels?: string[]; +}> { + const models = collectBackendModels(config, 'ollama'); + if (models.length === 0) { + return { message: 'No configured Ollama models were found to update.' }; + } + + const status = await fetchUnitStatus('ollama', true, runner); + if (status.loadState !== 'not-found' && status.activeState !== 'active') { + await runner(['--user', 'start', LOCAL_BACKEND_UNITS.ollama.unit, '--no-pager']); + // Briefly wait for the daemon to accept pull requests. + await delay(250); + } + + const updatedModels: string[] = []; + const failures: string[] = []; + + for (const model of models) { + try { + await execFile('ollama', ['pull', model], { timeout: 15 * 60_000, maxBuffer: 4 * 1024 * 1024 }); + updatedModels.push(model); + } catch (error) { + failures.push(`${model} (${normalizeError(error)})`); + } + } + + if (updatedModels.length === 0 && failures.length > 0) { + throw new Error(`Failed to update configured Ollama models: ${failures.join('; ')}`); + } + + if (failures.length > 0) { + return { + message: `Updated ${updatedModels.length} model(s); ${failures.length} failed.`, + updatedModels, + }; + } + + return { + message: `Updated ${updatedModels.length} model(s).`, + updatedModels, + }; +} + +async function updateLlamaCpp(runner: SystemctlRunner): Promise<{ message: string }> { + await runner(['--user', 'restart', LOCAL_BACKEND_UNITS.llamacpp.unit, '--no-pager']); + return { + message: 'Restarted llama.cpp service (model file updates are managed outside Flynn).', + }; +} + export async function listLocalBackendStatuses( config: Config, runner: SystemctlRunner = defaultRunner, @@ -265,8 +356,22 @@ export async function controlLocalBackend( ensureValidBackend(backend); ensureValidAction(action); - const unitDef = LOCAL_BACKEND_UNITS[backend]; - await runner(['--user', action, unitDef.unit, '--no-pager']); + let message: string | undefined; + let updatedModels: string[] | undefined; + + if (action === 'update') { + if (backend === 'ollama') { + const result = await updateOllama(config, runner); + message = result.message; + updatedModels = result.updatedModels; + } else { + const result = await updateLlamaCpp(runner); + message = result.message; + } + } else { + const unitDef = LOCAL_BACKEND_UNITS[backend]; + await runner(['--user', action, unitDef.unit, '--no-pager']); + } const configured = collectConfiguredLocalBackends(config); const status = await fetchUnitStatus(backend, configured.has(backend), runner); @@ -274,5 +379,7 @@ export async function controlLocalBackend( backend, action, status, + message, + updatedModels, }; } diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 5d38686..5e60948 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -324,8 +324,8 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { if (!params?.action || typeof params.action !== 'string') { return makeError(request.id, ErrorCode.InvalidRequest, 'action is required'); } - if (!['start', 'restart', 'stop'].includes(params.action)) { - return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop'); + if (!['start', 'restart', 'stop', 'update'].includes(params.action)) { + return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop, update'); } try { diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index ac60ea8..69fef36 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -37,6 +37,7 @@ const LOCAL_BACKEND_ACTION_LABELS = { start: 'Start', restart: 'Restart', stop: 'Stop', + update: 'Update', }; const SERVICE_TOGGLE_PATCH_PATHS = { heartbeat: 'automation.heartbeat.enabled', @@ -1569,7 +1570,7 @@ function updateLocalBackends(localBackendsData) { const loadText = backend.loadState || 'unknown'; const resultText = backend.result || 'unknown'; const availableActions = Array.isArray(backend.availableActions) - ? backend.availableActions.filter((value) => ['start', 'restart', 'stop'].includes(String(value))) + ? backend.availableActions.filter((value) => ['start', 'restart', 'stop', 'update'].includes(String(value))) : []; const actionMessage = actionState?.message ? `
${escapeHtml(String(actionState.message))}
` @@ -1623,6 +1624,7 @@ async function handleLocalBackendAction(backendId, action) { action, }); const status = result?.status; + const resultMessage = typeof result?.message === 'string' ? result.message : null; if (status && typeof status === 'object') { _lastLocalBackends = _lastLocalBackends.map((backend) => backend.id === backendId ? status : backend); @@ -1630,7 +1632,7 @@ async function handleLocalBackendAction(backendId, action) { _localBackendActionState.set(backendId, { pending: false, tone: 'success', - message: `${actionLabel} completed`, + message: resultMessage ? `${actionLabel} completed: ${resultMessage}` : `${actionLabel} completed`, }); updateLocalBackends({ backends: _lastLocalBackends }); diff --git a/src/gateway/ui/pages/dashboard.test.ts b/src/gateway/ui/pages/dashboard.test.ts index 085acb9..24aa0f1 100644 --- a/src/gateway/ui/pages/dashboard.test.ts +++ b/src/gateway/ui/pages/dashboard.test.ts @@ -106,7 +106,7 @@ function createMockClient() { pid: 111, result: 'success', statusText: 'active (running)', - availableActions: ['restart', 'stop'], + availableActions: ['restart', 'stop', 'update'], }, { id: 'llamacpp', @@ -122,7 +122,7 @@ function createMockClient() { pid: null, result: 'success', statusText: 'inactive/dead', - availableActions: ['start', 'restart'], + availableActions: ['start', 'restart', 'update'], }, ], calls: [] as Array<{ method: string; params?: Record }>, @@ -227,14 +227,16 @@ function createMockClient() { backend.subState = 'running'; backend.statusText = 'active (running)'; backend.pid = backend.id === 'ollama' ? 222 : 333; - backend.availableActions = ['restart', 'stop']; + backend.availableActions = ['restart', 'stop', 'update']; backend.result = 'success'; } else if (action === 'stop') { backend.activeState = 'inactive'; backend.subState = 'dead'; backend.statusText = 'inactive/dead'; backend.pid = null; - backend.availableActions = ['start', 'restart']; + backend.availableActions = ['start', 'restart', 'update']; + backend.result = 'success'; + } else if (action === 'update') { backend.result = 'success'; } @@ -242,6 +244,7 @@ function createMockClient() { backend: backendId, action, status: deepClone(backend), + message: action === 'update' ? 'Updated backend assets' : undefined, }; } return null; @@ -445,7 +448,7 @@ describe('DashboardPage assistant controls', () => { expect(toolCalls.some((entry) => entry.params?.tool === 'cron.trigger')).toBe(true); }); - it('renders local backend controls and triggers system.localBackendControl', async () => { + it('renders local backend controls and triggers system.localBackendControl actions', async () => { const { state, client } = createMockClient(); await DashboardPage.render(container, client); @@ -460,10 +463,16 @@ describe('DashboardPage assistant controls', () => { startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); await flush(); + const updateBtn = container.querySelector('#ops-local-backends .local-backend-action-btn[data-backend-id="ollama"][data-action="update"]'); + expect(updateBtn).toBeTruthy(); + updateBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); + await flush(); + const backendCalls = state.calls.filter((entry) => entry.method === 'system.localBackendControl'); - expect(backendCalls).toHaveLength(2); + expect(backendCalls).toHaveLength(3); expect(backendCalls[0].params).toEqual({ backend: 'ollama', action: 'restart' }); expect(backendCalls[1].params).toEqual({ backend: 'llamacpp', action: 'start' }); + expect(backendCalls[2].params).toEqual({ backend: 'ollama', action: 'update' }); expect(state.localBackends.find((entry) => entry.id === 'llamacpp')?.activeState).toBe('active'); }); });