gateway: add local backend daemon controls to dashboard
This commit is contained in:
@@ -10,6 +10,7 @@ import { createHistoryHandlers } from './history.js';
|
||||
import { createCanvasHandlers } from './canvas.js';
|
||||
import { createConfigHandlers, redactConfig } from './config.js';
|
||||
import { createPairingHandlers } from './pairing.js';
|
||||
import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js';
|
||||
import { PairingManager } from '../../channels/pairing.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { CanvasStore } from '../canvas-store.js';
|
||||
@@ -142,6 +143,107 @@ describe('system handlers', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('system.localBackends returns empty list when callback is not provided', async () => {
|
||||
const req: GatewayRequest = { id: 35, method: 'system.localBackends' };
|
||||
const result = await handlers['system.localBackends'](req) as GatewayResponse;
|
||||
expect(getPath(result.result, 'backends')).toEqual([]);
|
||||
});
|
||||
|
||||
it('system.localBackends returns backend statuses from callback', async () => {
|
||||
const localBackends: LocalBackendStatus[] = [
|
||||
{
|
||||
id: 'ollama',
|
||||
provider: 'ollama',
|
||||
name: 'Ollama',
|
||||
unit: 'ollama.service',
|
||||
configured: true,
|
||||
loadState: 'loaded',
|
||||
activeState: 'active',
|
||||
subState: 'running',
|
||||
unitFileState: 'enabled',
|
||||
description: 'Ollama Service',
|
||||
pid: 1234,
|
||||
result: 'success',
|
||||
statusText: 'active (running)',
|
||||
availableActions: ['restart', 'stop'],
|
||||
},
|
||||
];
|
||||
const getLocalBackends = vi.fn(async (): Promise<LocalBackendStatus[]> => localBackends);
|
||||
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
getLocalBackends,
|
||||
});
|
||||
const req: GatewayRequest = { id: 36, method: 'system.localBackends' };
|
||||
const result = await handlers['system.localBackends'](req) as GatewayResponse;
|
||||
expect(getLocalBackends).toHaveBeenCalledTimes(1);
|
||||
expect(getPath(result.result, 'backends')).toHaveLength(1);
|
||||
expect(getPath(result.result, 'backends', '0', 'id')).toBe('ollama');
|
||||
});
|
||||
|
||||
it('system.localBackendControl validates required params', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
controlLocalBackend: vi.fn(),
|
||||
});
|
||||
const missingBackend = await handlers['system.localBackendControl']({
|
||||
id: 37,
|
||||
method: 'system.localBackendControl',
|
||||
params: { action: 'restart' },
|
||||
}) as GatewayError;
|
||||
expect(missingBackend.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
|
||||
const missingAction = await handlers['system.localBackendControl']({
|
||||
id: 38,
|
||||
method: 'system.localBackendControl',
|
||||
params: { backend: 'ollama' },
|
||||
}) as GatewayError;
|
||||
expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
|
||||
const badAction = await handlers['system.localBackendControl']({
|
||||
id: 39,
|
||||
method: 'system.localBackendControl',
|
||||
params: { backend: 'ollama', action: 'reload' },
|
||||
}) as GatewayError;
|
||||
expect(badAction.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
});
|
||||
|
||||
it('system.localBackendControl forwards action to callback', async () => {
|
||||
const controlResult: LocalBackendControlResult = {
|
||||
backend: 'ollama',
|
||||
action: 'restart',
|
||||
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'],
|
||||
},
|
||||
};
|
||||
const controlLocalBackend = vi.fn(async (): Promise<LocalBackendControlResult> => controlResult);
|
||||
const handlers = createSystemHandlers({
|
||||
...deps,
|
||||
controlLocalBackend,
|
||||
});
|
||||
const req: GatewayRequest = {
|
||||
id: 40,
|
||||
method: 'system.localBackendControl',
|
||||
params: { backend: 'ollama', action: 'restart' },
|
||||
};
|
||||
const result = await handlers['system.localBackendControl'](req) as GatewayResponse;
|
||||
expect(controlLocalBackend).toHaveBeenCalledWith('ollama', 'restart');
|
||||
expect(getPath(result.result, 'status', 'activeState')).toBe('active');
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Config, ModelConfig } from '../../config/index.js';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export type LocalBackendId = 'ollama' | 'llamacpp';
|
||||
export type LocalBackendAction = 'start' | 'restart' | 'stop';
|
||||
|
||||
export interface LocalBackendStatus {
|
||||
id: LocalBackendId;
|
||||
provider: 'ollama' | 'llamacpp';
|
||||
name: string;
|
||||
unit: string;
|
||||
configured: boolean;
|
||||
loadState: string;
|
||||
activeState: string;
|
||||
subState: string;
|
||||
unitFileState: string;
|
||||
description: string;
|
||||
pid: number | null;
|
||||
result: string;
|
||||
statusText: string;
|
||||
availableActions: LocalBackendAction[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalBackendControlResult {
|
||||
backend: LocalBackendId;
|
||||
action: LocalBackendAction;
|
||||
status: LocalBackendStatus;
|
||||
}
|
||||
|
||||
type SystemctlResult = { stdout: string; stderr: string };
|
||||
type SystemctlRunner = (args: string[]) => Promise<SystemctlResult>;
|
||||
|
||||
const LOCAL_BACKEND_UNITS: Record<LocalBackendId, {
|
||||
provider: 'ollama' | 'llamacpp';
|
||||
name: string;
|
||||
unit: string;
|
||||
}> = {
|
||||
ollama: {
|
||||
provider: 'ollama',
|
||||
name: 'Ollama',
|
||||
unit: 'ollama.service',
|
||||
},
|
||||
llamacpp: {
|
||||
provider: 'llamacpp',
|
||||
name: 'llama.cpp server',
|
||||
unit: 'llama-server.service',
|
||||
},
|
||||
};
|
||||
|
||||
function defaultRunner(args: string[]): Promise<SystemctlResult> {
|
||||
return execFile('systemctl', args, { timeout: 10_000, maxBuffer: 1024 * 1024 }) as Promise<SystemctlResult>;
|
||||
}
|
||||
|
||||
function collectModelConfigs(config: Config): ModelConfig[] {
|
||||
const models: ModelConfig[] = [config.models.default];
|
||||
if (config.models.local) {models.push(config.models.local);}
|
||||
if (config.models.fast) {models.push(config.models.fast);}
|
||||
if (config.models.complex) {models.push(config.models.complex);}
|
||||
if (config.models.local_providers) {
|
||||
models.push(...Object.values(config.models.local_providers));
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function collectConfiguredLocalBackends(config: Config): Set<LocalBackendId> {
|
||||
const configured = new Set<LocalBackendId>();
|
||||
|
||||
for (const model of collectModelConfigs(config)) {
|
||||
if (model.provider === 'ollama') {
|
||||
configured.add('ollama');
|
||||
}
|
||||
if (model.provider === 'llamacpp') {
|
||||
configured.add('llamacpp');
|
||||
}
|
||||
}
|
||||
|
||||
const embeddingProvider = config.memory.embedding.provider;
|
||||
if (embeddingProvider === 'ollama') {
|
||||
configured.add('ollama');
|
||||
}
|
||||
if (embeddingProvider === 'llamacpp') {
|
||||
configured.add('llamacpp');
|
||||
}
|
||||
|
||||
if (config.audio.enabled) {
|
||||
const audioProvider = config.audio.provider?.type;
|
||||
if (audioProvider === 'ollama') {
|
||||
configured.add('ollama');
|
||||
}
|
||||
if (audioProvider === 'llamacpp') {
|
||||
configured.add('llamacpp');
|
||||
}
|
||||
}
|
||||
|
||||
return configured;
|
||||
}
|
||||
|
||||
function parseKeyValueOutput(output: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line.trim()) {continue;}
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) {continue;}
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const maybe = error as { stderr?: string; stdout?: string; message?: string };
|
||||
const stderr = maybe.stderr?.trim();
|
||||
if (stderr) {return stderr;}
|
||||
const stdout = maybe.stdout?.trim();
|
||||
if (stdout) {return stdout;}
|
||||
if (typeof maybe.message === 'string' && maybe.message.trim()) {return maybe.message.trim();}
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function isUnitMissing(errorText: string): boolean {
|
||||
return (
|
||||
errorText.includes('could not be found')
|
||||
|| errorText.includes('not-found')
|
||||
|| errorText.includes('No such file or directory')
|
||||
);
|
||||
}
|
||||
|
||||
function computeAvailableActions(activeState: string, loadState: string): LocalBackendAction[] {
|
||||
if (loadState === 'not-found') {
|
||||
return [];
|
||||
}
|
||||
if (activeState === 'active') {
|
||||
return ['restart', 'stop'];
|
||||
}
|
||||
if (activeState === 'inactive' || activeState === 'failed') {
|
||||
return ['start', 'restart'];
|
||||
}
|
||||
if (activeState === 'activating' || activeState === 'deactivating') {
|
||||
return ['restart', 'stop'];
|
||||
}
|
||||
return ['start', 'restart', 'stop'];
|
||||
}
|
||||
|
||||
function buildStatusText(activeState: string, subState: string): string {
|
||||
if (activeState === 'active' && subState) {
|
||||
return `${activeState} (${subState})`;
|
||||
}
|
||||
if (activeState && subState && activeState !== subState) {
|
||||
return `${activeState}/${subState}`;
|
||||
}
|
||||
return activeState || subState || 'unknown';
|
||||
}
|
||||
|
||||
async function fetchUnitStatus(
|
||||
id: LocalBackendId,
|
||||
configured: boolean,
|
||||
runner: SystemctlRunner,
|
||||
): Promise<LocalBackendStatus> {
|
||||
const unitDef = LOCAL_BACKEND_UNITS[id];
|
||||
const base: Omit<LocalBackendStatus, 'availableActions' | 'statusText'> = {
|
||||
id,
|
||||
provider: unitDef.provider,
|
||||
name: unitDef.name,
|
||||
unit: unitDef.unit,
|
||||
configured,
|
||||
loadState: 'unknown',
|
||||
activeState: 'unknown',
|
||||
subState: 'unknown',
|
||||
unitFileState: 'unknown',
|
||||
description: unitDef.name,
|
||||
pid: null,
|
||||
result: 'unknown',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await runner([
|
||||
'--user',
|
||||
'show',
|
||||
unitDef.unit,
|
||||
'--property=LoadState,ActiveState,SubState,UnitFileState,Description,ExecMainPID,Result',
|
||||
'--no-pager',
|
||||
]);
|
||||
const parsed = parseKeyValueOutput(response.stdout);
|
||||
const pidRaw = parsed.ExecMainPID;
|
||||
const pid = pidRaw ? Number(pidRaw) : NaN;
|
||||
|
||||
const status: LocalBackendStatus = {
|
||||
...base,
|
||||
loadState: parsed.LoadState || base.loadState,
|
||||
activeState: parsed.ActiveState || base.activeState,
|
||||
subState: parsed.SubState || base.subState,
|
||||
unitFileState: parsed.UnitFileState || base.unitFileState,
|
||||
description: parsed.Description || base.description,
|
||||
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
||||
result: parsed.Result || base.result,
|
||||
statusText: '',
|
||||
availableActions: [],
|
||||
};
|
||||
status.availableActions = computeAvailableActions(status.activeState, status.loadState);
|
||||
status.statusText = buildStatusText(status.activeState, status.subState);
|
||||
return status;
|
||||
} catch (error) {
|
||||
const detail = normalizeError(error);
|
||||
if (isUnitMissing(detail)) {
|
||||
const missingStatus: LocalBackendStatus = {
|
||||
...base,
|
||||
loadState: 'not-found',
|
||||
activeState: 'inactive',
|
||||
subState: 'dead',
|
||||
result: 'not-found',
|
||||
unitFileState: 'not-found',
|
||||
statusText: 'missing',
|
||||
availableActions: [],
|
||||
error: detail,
|
||||
};
|
||||
return missingStatus;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
statusText: 'unknown',
|
||||
availableActions: ['start', 'restart', 'stop'],
|
||||
error: detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidAction(action: string): asserts action is LocalBackendAction {
|
||||
if (action !== 'start' && action !== 'restart' && action !== 'stop') {
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidBackend(id: string): asserts id is LocalBackendId {
|
||||
if (id !== 'ollama' && id !== 'llamacpp') {
|
||||
throw new Error(`Unsupported backend: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLocalBackendStatuses(
|
||||
config: Config,
|
||||
runner: SystemctlRunner = defaultRunner,
|
||||
): Promise<LocalBackendStatus[]> {
|
||||
const configured = collectConfiguredLocalBackends(config);
|
||||
return Promise.all((Object.keys(LOCAL_BACKEND_UNITS) as LocalBackendId[]).map((id) =>
|
||||
fetchUnitStatus(id, configured.has(id), runner),
|
||||
));
|
||||
}
|
||||
|
||||
export async function controlLocalBackend(
|
||||
config: Config,
|
||||
backend: string,
|
||||
action: string,
|
||||
runner: SystemctlRunner = defaultRunner,
|
||||
): Promise<LocalBackendControlResult> {
|
||||
ensureValidBackend(backend);
|
||||
ensureValidAction(action);
|
||||
|
||||
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);
|
||||
return {
|
||||
backend,
|
||||
action,
|
||||
status,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.
|
||||
import type { ServiceInfo } from './services.js';
|
||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||
import type { SessionAnalyticsSnapshot } from '../../session/index.js';
|
||||
import type { LocalBackendAction, LocalBackendControlResult, LocalBackendStatus } from './localBackends.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -103,6 +104,20 @@ export interface SystemHandlerDeps {
|
||||
error?: string;
|
||||
fetchedAt: number;
|
||||
}>>;
|
||||
/** Optional callback to retrieve local backend daemon statuses. */
|
||||
getLocalBackends?: () => Promise<LocalBackendStatus[]> | LocalBackendStatus[];
|
||||
/** Optional callback to control local backend daemons. */
|
||||
controlLocalBackend?: (backend: string, action: string) => Promise<LocalBackendControlResult>;
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string' && error.trim().length > 0) {
|
||||
return error;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
@@ -285,5 +300,40 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
|
||||
});
|
||||
return makeResponse(request.id, { providers });
|
||||
},
|
||||
|
||||
'system.localBackends': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.getLocalBackends) {
|
||||
return makeResponse(request.id, { backends: [] });
|
||||
}
|
||||
try {
|
||||
const backends = await deps.getLocalBackends();
|
||||
return makeResponse(request.id, { backends });
|
||||
} catch (error) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Failed to load local backends: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
'system.localBackendControl': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
if (!deps.controlLocalBackend) {
|
||||
return makeError(request.id, ErrorCode.InternalError, 'Local backend control is not available in this environment');
|
||||
}
|
||||
const params = request.params as { backend?: string; action?: LocalBackendAction } | undefined;
|
||||
if (!params?.backend || typeof params.backend !== 'string') {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'backend is required');
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.controlLocalBackend(params.backend, params.action);
|
||||
return makeResponse(request.id, result);
|
||||
} catch (error) {
|
||||
return makeError(request.id, ErrorCode.InternalError, `Local backend control failed: ${normalizeErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user