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`
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
+16
View File
@@ -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",
+39
View File
@@ -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<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 () => {
const req: GatewayRequest = { id: 4, method: 'system.presence' };
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);
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<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(
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,
};
}
+2 -2
View File
@@ -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 {
+4 -2
View File
@@ -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
? `<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,
});
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 });
+15 -6
View File
@@ -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<string, unknown> }>,
@@ -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');
});
});