gateway: add local backend update action

This commit is contained in:
William Valentin
2026-02-22 16:57:57 -08:00
parent c48f6f5fd3
commit c79e082905
7 changed files with 199 additions and 22 deletions
+5 -1
View File
@@ -384,7 +384,11 @@ Return status for user-level local LLM backend daemons (for example `ollama.serv
#### `system.localBackendControl` #### `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:** **Request:**
```json ```json
+16
View File
@@ -3,6 +3,22 @@
"updated_at": "2026-02-23", "updated_at": "2026-02-23",
"description": "Tracks the status of all Flynn plans and implementation phases", "description": "Tracks the status of all Flynn plans and implementation phases",
"plans": { "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": { "dashboard-local-backend-daemon-controls": {
"status": "completed", "status": "completed",
"date": "2026-02-23", "date": "2026-02-23",
+39
View File
@@ -244,6 +244,45 @@ describe('system handlers', () => {
expect(getPath(result.result, 'status', 'activeState')).toBe('active'); expect(getPath(result.result, 'status', 'activeState')).toBe('active');
}); });
it('system.localBackendControl accepts update action', async () => {
const controlLocalBackend = vi.fn(async (): Promise<LocalBackendControlResult> => ({
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 () => { it('system.presence returns empty result when getPresence is not provided', async () => {
const req: GatewayRequest = { id: 4, method: 'system.presence' }; const req: GatewayRequest = { id: 4, method: 'system.presence' };
const result = await handlers['system.presence'](req) as GatewayResponse; const result = await handlers['system.presence'](req) as GatewayResponse;
+118 -11
View File
@@ -5,7 +5,7 @@ import type { Config, ModelConfig } from '../../config/index.js';
const execFile = promisify(execFileCb); const execFile = promisify(execFileCb);
export type LocalBackendId = 'ollama' | 'llamacpp'; export type LocalBackendId = 'ollama' | 'llamacpp';
export type LocalBackendAction = 'start' | 'restart' | 'stop'; export type LocalBackendAction = 'start' | 'restart' | 'stop' | 'update';
export interface LocalBackendStatus { export interface LocalBackendStatus {
id: LocalBackendId; id: LocalBackendId;
@@ -29,6 +29,8 @@ export interface LocalBackendControlResult {
backend: LocalBackendId; backend: LocalBackendId;
action: LocalBackendAction; action: LocalBackendAction;
status: LocalBackendStatus; status: LocalBackendStatus;
message?: string;
updatedModels?: string[];
} }
type SystemctlResult = { stdout: string; stderr: 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') { if (loadState === 'not-found') {
return []; return [];
} }
const withUpdate = (actions: LocalBackendAction[]): LocalBackendAction[] => {
if (configured && !actions.includes('update')) {
return [...actions, 'update'];
}
return actions;
};
if (activeState === 'active') { if (activeState === 'active') {
return ['restart', 'stop']; return withUpdate(['restart', 'stop']);
} }
if (activeState === 'inactive' || activeState === 'failed') { if (activeState === 'inactive' || activeState === 'failed') {
return ['start', 'restart']; return withUpdate(['start', 'restart']);
} }
if (activeState === 'activating' || activeState === 'deactivating') { 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 { function buildStatusText(activeState: string, subState: string): string {
@@ -206,7 +214,7 @@ async function fetchUnitStatus(
statusText: '', statusText: '',
availableActions: [], availableActions: [],
}; };
status.availableActions = computeAvailableActions(status.activeState, status.loadState); status.availableActions = computeAvailableActions(status.activeState, status.loadState, configured);
status.statusText = buildStatusText(status.activeState, status.subState); status.statusText = buildStatusText(status.activeState, status.subState);
return status; return status;
} catch (error) { } catch (error) {
@@ -228,14 +236,14 @@ async function fetchUnitStatus(
return { return {
...base, ...base,
statusText: 'unknown', statusText: 'unknown',
availableActions: ['start', 'restart', 'stop'], availableActions: computeAvailableActions(base.activeState, base.loadState, configured),
error: detail, error: detail,
}; };
} }
} }
function ensureValidAction(action: string): asserts action is LocalBackendAction { 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}`); 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<string>();
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<void> {
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( export async function listLocalBackendStatuses(
config: Config, config: Config,
runner: SystemctlRunner = defaultRunner, runner: SystemctlRunner = defaultRunner,
@@ -265,8 +356,22 @@ export async function controlLocalBackend(
ensureValidBackend(backend); ensureValidBackend(backend);
ensureValidAction(action); ensureValidAction(action);
const unitDef = LOCAL_BACKEND_UNITS[backend]; let message: string | undefined;
await runner(['--user', action, unitDef.unit, '--no-pager']); 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 configured = collectConfiguredLocalBackends(config);
const status = await fetchUnitStatus(backend, configured.has(backend), runner); const status = await fetchUnitStatus(backend, configured.has(backend), runner);
@@ -274,5 +379,7 @@ export async function controlLocalBackend(
backend, backend,
action, action,
status, status,
message,
updatedModels,
}; };
} }
+2 -2
View File
@@ -324,8 +324,8 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
if (!params?.action || typeof params.action !== 'string') { if (!params?.action || typeof params.action !== 'string') {
return makeError(request.id, ErrorCode.InvalidRequest, 'action is required'); return makeError(request.id, ErrorCode.InvalidRequest, 'action is required');
} }
if (!['start', 'restart', 'stop'].includes(params.action)) { if (!['start', 'restart', 'stop', 'update'].includes(params.action)) {
return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop'); return makeError(request.id, ErrorCode.InvalidRequest, 'action must be one of: start, restart, stop, update');
} }
try { try {
+4 -2
View File
@@ -37,6 +37,7 @@ const LOCAL_BACKEND_ACTION_LABELS = {
start: 'Start', start: 'Start',
restart: 'Restart', restart: 'Restart',
stop: 'Stop', stop: 'Stop',
update: 'Update',
}; };
const SERVICE_TOGGLE_PATCH_PATHS = { const SERVICE_TOGGLE_PATCH_PATHS = {
heartbeat: 'automation.heartbeat.enabled', heartbeat: 'automation.heartbeat.enabled',
@@ -1569,7 +1570,7 @@ function updateLocalBackends(localBackendsData) {
const loadText = backend.loadState || 'unknown'; const loadText = backend.loadState || 'unknown';
const resultText = backend.result || 'unknown'; const resultText = backend.result || 'unknown';
const availableActions = Array.isArray(backend.availableActions) 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 const actionMessage = actionState?.message
? `<div class="text-xs ${actionState.tone === 'error' ? 'text-red-400' : actionState.tone === 'success' ? 'text-green-400' : 'text-zinc-400'}">${escapeHtml(String(actionState.message))}</div>` ? `<div class="text-xs ${actionState.tone === 'error' ? 'text-red-400' : actionState.tone === 'success' ? 'text-green-400' : 'text-zinc-400'}">${escapeHtml(String(actionState.message))}</div>`
@@ -1623,6 +1624,7 @@ async function handleLocalBackendAction(backendId, action) {
action, action,
}); });
const status = result?.status; const status = result?.status;
const resultMessage = typeof result?.message === 'string' ? result.message : null;
if (status && typeof status === 'object') { if (status && typeof status === 'object') {
_lastLocalBackends = _lastLocalBackends.map((backend) => _lastLocalBackends = _lastLocalBackends.map((backend) =>
backend.id === backendId ? status : backend); backend.id === backendId ? status : backend);
@@ -1630,7 +1632,7 @@ async function handleLocalBackendAction(backendId, action) {
_localBackendActionState.set(backendId, { _localBackendActionState.set(backendId, {
pending: false, pending: false,
tone: 'success', tone: 'success',
message: `${actionLabel} completed`, message: resultMessage ? `${actionLabel} completed: ${resultMessage}` : `${actionLabel} completed`,
}); });
updateLocalBackends({ backends: _lastLocalBackends }); updateLocalBackends({ backends: _lastLocalBackends });
+15 -6
View File
@@ -106,7 +106,7 @@ function createMockClient() {
pid: 111, pid: 111,
result: 'success', result: 'success',
statusText: 'active (running)', statusText: 'active (running)',
availableActions: ['restart', 'stop'], availableActions: ['restart', 'stop', 'update'],
}, },
{ {
id: 'llamacpp', id: 'llamacpp',
@@ -122,7 +122,7 @@ function createMockClient() {
pid: null, pid: null,
result: 'success', result: 'success',
statusText: 'inactive/dead', statusText: 'inactive/dead',
availableActions: ['start', 'restart'], availableActions: ['start', 'restart', 'update'],
}, },
], ],
calls: [] as Array<{ method: string; params?: Record<string, unknown> }>, calls: [] as Array<{ method: string; params?: Record<string, unknown> }>,
@@ -227,14 +227,16 @@ function createMockClient() {
backend.subState = 'running'; backend.subState = 'running';
backend.statusText = 'active (running)'; backend.statusText = 'active (running)';
backend.pid = backend.id === 'ollama' ? 222 : 333; backend.pid = backend.id === 'ollama' ? 222 : 333;
backend.availableActions = ['restart', 'stop']; backend.availableActions = ['restart', 'stop', 'update'];
backend.result = 'success'; backend.result = 'success';
} else if (action === 'stop') { } else if (action === 'stop') {
backend.activeState = 'inactive'; backend.activeState = 'inactive';
backend.subState = 'dead'; backend.subState = 'dead';
backend.statusText = 'inactive/dead'; backend.statusText = 'inactive/dead';
backend.pid = null; backend.pid = null;
backend.availableActions = ['start', 'restart']; backend.availableActions = ['start', 'restart', 'update'];
backend.result = 'success';
} else if (action === 'update') {
backend.result = 'success'; backend.result = 'success';
} }
@@ -242,6 +244,7 @@ function createMockClient() {
backend: backendId, backend: backendId,
action, action,
status: deepClone(backend), status: deepClone(backend),
message: action === 'update' ? 'Updated backend assets' : undefined,
}; };
} }
return null; return null;
@@ -445,7 +448,7 @@ describe('DashboardPage assistant controls', () => {
expect(toolCalls.some((entry) => entry.params?.tool === 'cron.trigger')).toBe(true); 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(); const { state, client } = createMockClient();
await DashboardPage.render(container, client); await DashboardPage.render(container, client);
@@ -460,10 +463,16 @@ describe('DashboardPage assistant controls', () => {
startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true })); startBtn.dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await flush(); 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'); 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[0].params).toEqual({ backend: 'ollama', action: 'restart' });
expect(backendCalls[1].params).toEqual({ backend: 'llamacpp', action: 'start' }); 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'); expect(state.localBackends.find((entry) => entry.id === 'llamacpp')?.activeState).toBe('active');
}); });
}); });