gateway: add local backend update action
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user