mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 10:33:00 +08:00
feat: add Hermes gateway controls
This commit is contained in:
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean);
|
||||
const fallbackHermesCommand = (
|
||||
process.env.HERMES_COMMAND_PATH
|
||||
|| process.env.HERMES_CLI_PATH
|
||||
|| 'hermes'
|
||||
).trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand;
|
||||
const HERMES_BASE_ARGS = gatewayCommandParts.slice(1);
|
||||
|
||||
const MAX_LOG_LINES = 300;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type HermesGatewayProfile = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
model: string | null;
|
||||
gateway: string | null;
|
||||
alias: string | null;
|
||||
distribution: string | null;
|
||||
};
|
||||
|
||||
export type HermesGatewayStatus = {
|
||||
installed: boolean;
|
||||
command: string;
|
||||
version: string | null;
|
||||
running: boolean;
|
||||
managedByCloudCLI: boolean;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
statusOutput: string;
|
||||
profiles: HermesGatewayProfile[];
|
||||
logs: string[];
|
||||
lastExit: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
at: string;
|
||||
} | null;
|
||||
commands: {
|
||||
setup: string;
|
||||
run: string;
|
||||
};
|
||||
};
|
||||
|
||||
const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
|
||||
const compactOutput = (result: CommandResult): string => (
|
||||
[result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
||||
);
|
||||
|
||||
const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => {
|
||||
if (managedByCloudCLI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\b(running|active)\b/.test(normalized);
|
||||
};
|
||||
|
||||
const parseProfiles = (output: string): HermesGatewayProfile[] => {
|
||||
const profiles: HermesGatewayProfile[] = [];
|
||||
for (const rawLine of removeAnsi(output).split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = line.startsWith('◆');
|
||||
const cleaned = line.replace(/^[◆*]\s*/, '').trim();
|
||||
const columns = cleaned.split(/\s{2,}/).map((column) => column.trim());
|
||||
const [name, model, gateway, alias, distribution] = columns;
|
||||
if (!name || name.toLowerCase() === 'profile') {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
name,
|
||||
current,
|
||||
model: model && model !== '—' ? model : null,
|
||||
gateway: gateway && gateway !== '—' ? gateway : null,
|
||||
alias: alias && alias !== '—' ? alias : null,
|
||||
distribution: distribution && distribution !== '—' ? distribution : null,
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
class HermesGatewayService {
|
||||
private gatewayProcess: ChildProcess | null = null;
|
||||
private readonly logs: string[] = [];
|
||||
private lastExit: HermesGatewayStatus['lastExit'] = null;
|
||||
|
||||
async getStatus(): Promise<HermesGatewayStatus> {
|
||||
const versionResult = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
const installed = versionResult.exitCode === 0;
|
||||
const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null;
|
||||
|
||||
const statusResult = installed
|
||||
? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 };
|
||||
const profilesResult = installed
|
||||
? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: '', exitCode: 1 };
|
||||
|
||||
const statusOutput = compactOutput(statusResult);
|
||||
const managedByCloudCLI = this.isManagedProcessRunning();
|
||||
const running = parseGatewayRunning(statusOutput, managedByCloudCLI);
|
||||
|
||||
return {
|
||||
installed,
|
||||
command: this.commandPrefix().join(' '),
|
||||
version,
|
||||
running,
|
||||
managedByCloudCLI,
|
||||
state: running ? 'running' : statusOutput ? 'stopped' : 'unknown',
|
||||
statusOutput,
|
||||
profiles: parseProfiles(profilesResult.stdout),
|
||||
logs: this.getLogs(),
|
||||
lastExit: this.lastExit,
|
||||
commands: {
|
||||
setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '),
|
||||
run: [...this.commandPrefix(), 'gateway', 'run'].join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<HermesGatewayStatus> {
|
||||
await this.assertInstalled();
|
||||
if (this.isManagedProcessRunning()) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const currentStatus = await this.getStatus();
|
||||
if (currentStatus.running) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks'];
|
||||
this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`);
|
||||
this.lastExit = null;
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.gatewayProcess = child;
|
||||
|
||||
child.stdout?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.on('error', (error) => {
|
||||
this.appendLog(`[cloudcli] gateway process error: ${error.message}`);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.lastExit = {
|
||||
code,
|
||||
signal,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`);
|
||||
this.gatewayProcess = null;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
if (!this.isManagedProcessRunning()) {
|
||||
throw new AppError('Hermes gateway exited before it could start.', {
|
||||
code: 'HERMES_GATEWAY_START_FAILED',
|
||||
statusCode: 500,
|
||||
details: {
|
||||
logs: this.getLogs().slice(-20),
|
||||
lastExit: this.lastExit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning() && this.gatewayProcess) {
|
||||
this.appendLog('[cloudcli] stopping managed Hermes gateway');
|
||||
await this.stopManagedProcess();
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restart(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning()) {
|
||||
await this.stopManagedProcess();
|
||||
} else {
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
return this.start();
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
private async assertInstalled(): Promise<void> {
|
||||
const result = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
if (result.exitCode !== 0) {
|
||||
throw new AppError('Hermes is not installed or is not available on PATH.', {
|
||||
code: 'HERMES_NOT_INSTALLED',
|
||||
statusCode: 400,
|
||||
details: compactOutput(result),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isManagedProcessRunning(): boolean {
|
||||
return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null);
|
||||
}
|
||||
|
||||
private async stopManagedProcess(): Promise<void> {
|
||||
const child = this.gatewayProcess;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (this.gatewayProcess === child && this.isManagedProcessRunning()) {
|
||||
this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async runHermes(args: string[], options: { timeout: number }): Promise<CommandResult> {
|
||||
try {
|
||||
const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stdout: execError.stdout ?? '',
|
||||
stderr: execError.stderr ?? '',
|
||||
exitCode: typeof execError.code === 'number' ? execError.code : 1,
|
||||
error: execError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private appendLog(chunk: string): void {
|
||||
const lines = removeAnsi(chunk)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`));
|
||||
if (this.logs.length > MAX_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
private commandPrefix(): string[] {
|
||||
return [HERMES_COMMAND, ...HERMES_BASE_ARGS];
|
||||
}
|
||||
}
|
||||
|
||||
export const hermesGatewayService = new HermesGatewayService();
|
||||
Reference in New Issue
Block a user