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
+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 {