Improve profile runtime controls (#868)
* Improve profile runtime controls * Restore profile selector test id * Update profile switch e2e flow
This commit is contained in:
@@ -5,6 +5,10 @@ import { tmpdir } from 'os'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||
import {
|
||||
getGatewayRuntimeStatusForProfile,
|
||||
restartGatewayForProfile as restartGatewayRuntimeForProfile,
|
||||
} from '../../services/hermes/gateway-autostart'
|
||||
import { logger } from '../../services/logger'
|
||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||
@@ -84,6 +88,10 @@ function deleteForbiddenProfileFromDisk(name: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
|
||||
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
|
||||
}
|
||||
|
||||
async function useProfileWithFallback(name: string): Promise<string> {
|
||||
if (isForbiddenProfileName(name)) {
|
||||
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
|
||||
@@ -100,6 +108,62 @@ async function useProfileWithFallback(name: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readBridgeWorkers(): Promise<{ reachable: boolean; workers: Record<string, boolean>; error?: string }> {
|
||||
try {
|
||||
const result = await new AgentBridgeClient({ timeoutMs: 5000 }).ping()
|
||||
return {
|
||||
reachable: true,
|
||||
workers: ((result as any).workers || {}) as Record<string, boolean>,
|
||||
}
|
||||
} catch (err: any) {
|
||||
return {
|
||||
reachable: false,
|
||||
workers: {},
|
||||
error: err?.message || 'Bridge broker is not reachable',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gatewayStatusLooksRunning(status?: string): boolean {
|
||||
const normalized = String(status || '').trim().toLowerCase()
|
||||
if (!normalized || normalized === '—') return false
|
||||
if (normalized.includes('not running') || normalized === 'stopped' || normalized === 'stop') return false
|
||||
return normalized.includes('running') || normalized === 'active'
|
||||
}
|
||||
|
||||
async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?: Awaited<ReturnType<typeof readBridgeWorkers>>) {
|
||||
const name = typeof profile === 'string' ? profile : profile.name
|
||||
const bridge = bridgeState || await readBridgeWorkers()
|
||||
let gateway: { running: boolean; profile: string; error?: string }
|
||||
if (typeof profile !== 'string' && profile.gatewayStatus !== undefined) {
|
||||
gateway = {
|
||||
running: gatewayStatusLooksRunning(profile.gatewayStatus),
|
||||
profile: name,
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
gateway = await getGatewayRuntimeStatusForProfile(name)
|
||||
} catch (err: any) {
|
||||
gateway = {
|
||||
running: false,
|
||||
profile: name,
|
||||
error: err?.message || 'Gateway status check failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profile: name,
|
||||
bridge: {
|
||||
running: !!bridge.workers[name],
|
||||
profile: name,
|
||||
reachable: bridge.reachable,
|
||||
error: bridge.reachable ? undefined : bridge.error,
|
||||
},
|
||||
gateway,
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(ctx: any) {
|
||||
try {
|
||||
let profiles: HermesProfile[]
|
||||
@@ -120,6 +184,8 @@ export async function list(ctx: any) {
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const activeProfileName = getActiveProfileName()
|
||||
|
||||
profiles = filterVisibleProfiles(profiles)
|
||||
|
||||
// Check if CLI's active flag matches the file (warn if inconsistent)
|
||||
const cliActive = profiles.find(p => p.active)
|
||||
if (cliActive?.name !== activeProfileName) {
|
||||
@@ -209,6 +275,89 @@ export async function get(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeStatus(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const profiles = await listProfilesForStatus()
|
||||
const profile = profiles.find(item => item.name === name)
|
||||
ctx.body = await buildRuntimeStatus(profile || name)
|
||||
} catch {
|
||||
ctx.body = await buildRuntimeStatus(name)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runtimeStatuses(ctx: any) {
|
||||
try {
|
||||
const profiles = await listProfilesForStatus()
|
||||
const bridge = await readBridgeWorkers()
|
||||
const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge)))
|
||||
ctx.body = { profiles: statuses }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function listProfilesForStatus(): Promise<HermesProfile[]> {
|
||||
let profiles: HermesProfile[]
|
||||
try {
|
||||
profiles = await hermesCli.listProfiles()
|
||||
} catch {
|
||||
profiles = listProfilesFromDisk(getActiveProfileName())
|
||||
}
|
||||
return filterVisibleProfiles(profiles)
|
||||
}
|
||||
|
||||
export async function restartGatewayForProfile(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const gateway = await restartGatewayRuntimeForProfile(name)
|
||||
try {
|
||||
const result = await bridgeCleanupClient().destroyProfile(name)
|
||||
logger.info('[profiles] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', name, result.destroyed)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[profiles] failed to destroy bridge sessions after gateway restart profile=%s', name)
|
||||
}
|
||||
ctx.body = { success: true, gateway }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartProfileRuntime(ctx: any) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await bridgeCleanupClient().destroyProfile(name)
|
||||
logger.info('[profiles] destroyed bridge sessions after profile restart profile=%s destroyed=%s', name, result.destroyed)
|
||||
const profiles = await listProfilesForStatus()
|
||||
const profile = profiles.find(item => item.name === name)
|
||||
ctx.body = {
|
||||
success: true,
|
||||
destroyed: result.destroyed,
|
||||
status: await buildRuntimeStatus(profile || name),
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(ctx: any) {
|
||||
const { name } = ctx.params
|
||||
if (name === 'default') {
|
||||
|
||||
@@ -5,6 +5,10 @@ export const profileRoutes = new Router()
|
||||
|
||||
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
||||
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
||||
profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
|
||||
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
|
||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
||||
|
||||
Regular → Executable
+1
@@ -102,6 +102,7 @@ def _candidate_agent_roots(raw: str | None = None) -> list[Path]:
|
||||
Path.home() / "hermes-agent",
|
||||
Path("/opt/hermes/hermes-agent"),
|
||||
Path("/opt/hermes-agent"),
|
||||
Path("/usr/local/lib/hermes-agent"),
|
||||
Path("/usr/local/hermes-agent"),
|
||||
])
|
||||
candidates.append(Path(DEFAULT_AGENT_ROOT).expanduser())
|
||||
|
||||
@@ -99,6 +99,7 @@ function agentRootFromHermesBin(): string | undefined {
|
||||
resolve(binDir, '..'),
|
||||
resolve(binDir, '..', '..'),
|
||||
resolve(binDir, '..', 'hermes-agent'),
|
||||
resolve(binDir, '..', 'lib', 'hermes-agent'),
|
||||
resolve(binDir, '..', '..', 'hermes-agent'),
|
||||
]
|
||||
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
@@ -113,6 +114,7 @@ function agentRootFromHermesBin(): string | undefined {
|
||||
const shebangRootCandidates = [
|
||||
resolve(pyDir, '..', '..'),
|
||||
resolve(pyDir, '..', '..', 'hermes-agent'),
|
||||
resolve(pyDir, '..', '..', 'lib', 'hermes-agent'),
|
||||
]
|
||||
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
@@ -155,6 +157,10 @@ function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): s
|
||||
agentRootFromHermesBin(),
|
||||
process.cwd(),
|
||||
join(process.cwd(), 'hermes-agent'),
|
||||
'/usr/local/lib/hermes-agent',
|
||||
'/usr/local/hermes-agent',
|
||||
'/opt/hermes/hermes-agent',
|
||||
'/opt/hermes-agent',
|
||||
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
|
||||
@@ -10,10 +10,27 @@ import { startGatewayRunManaged } from './gateway-runner'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const RESERVED_PROFILE_NAMES = new Set([
|
||||
'hermes', 'test', 'tmp', 'root', 'sudo',
|
||||
])
|
||||
|
||||
const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([
|
||||
'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout',
|
||||
'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools',
|
||||
'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall',
|
||||
'profile', 'plugins', 'honcho', 'acp',
|
||||
])
|
||||
|
||||
function resolveHermesBin(): string {
|
||||
return process.env.HERMES_BIN?.trim() || 'hermes'
|
||||
}
|
||||
|
||||
function isReservedProfileName(profile: string): boolean {
|
||||
const normalized = String(profile || '').trim().toLowerCase()
|
||||
if (!normalized || normalized === 'default') return false
|
||||
return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized)
|
||||
}
|
||||
|
||||
function isDockerRuntime(): boolean {
|
||||
return existsSync('/.dockerenv')
|
||||
}
|
||||
@@ -25,6 +42,10 @@ function isTermuxRuntime(): boolean {
|
||||
existsSync('/data/data/com.termux/files/usr')
|
||||
}
|
||||
|
||||
function requiresManagedGatewayRun(): boolean {
|
||||
return isDockerRuntime() || isTermuxRuntime() || process.platform === 'win32'
|
||||
}
|
||||
|
||||
export function gatewayStatusLooksRunning(output: string): boolean {
|
||||
const text = output.toLowerCase()
|
||||
if (text.includes('gateway is not running') || text.includes('not running')) return false
|
||||
@@ -38,6 +59,37 @@ export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
|
||||
|| text.includes('already held by another instance')
|
||||
}
|
||||
|
||||
export function parseGatewayStatusesFromProfileListOutput(stdout: string): Map<string, string> {
|
||||
const statuses = new Map<string, string>()
|
||||
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
const lines = normalized.trim().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue
|
||||
|
||||
const body = trimmed.startsWith('◆') ? trimmed.slice(1).trim() : trimmed
|
||||
const columns = body.split(/\s{2,}/).map(part => part.trim())
|
||||
if (columns.length >= 3 && columns[0]) {
|
||||
statuses.set(columns[0], columns[2])
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
async function listGatewayStatusesFromProfileList(hermesBin: string): Promise<Map<string, string>> {
|
||||
const { stdout } = await execFileAsync(hermesBin, ['profile', 'list'], {
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
})
|
||||
return parseGatewayStatusesFromProfileListOutput(stdout)
|
||||
}
|
||||
|
||||
async function isGatewayRunningInProfileList(hermesBin: string, profile: string): Promise<boolean> {
|
||||
const statuses = await listGatewayStatusesFromProfileList(hermesBin)
|
||||
const status = statuses.get(profile)
|
||||
return status !== undefined && gatewayStatusLooksRunning(status)
|
||||
}
|
||||
|
||||
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
|
||||
@@ -62,8 +114,38 @@ export async function isGatewayRunningForProfile(hermesBin: string, profileDir:
|
||||
}
|
||||
}
|
||||
|
||||
async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
|
||||
if (isDockerRuntime() || isTermuxRuntime()) {
|
||||
async function waitForGatewayRunning(hermesBin: string, profile: string, profileDir: string, timeoutMs = 15000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await isGatewayRunningInProfileList(hermesBin, profile)) return true
|
||||
} catch (err) {
|
||||
logger.warn(err, '[gateway-autostart] Hermes profile list check failed while waiting for gateway profile=%s', profile)
|
||||
}
|
||||
if (await isGatewayRunningForProfile(hermesBin, profileDir)) return true
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function stopGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(hermesBin, ['gateway', 'stop'], {
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: profileDir,
|
||||
},
|
||||
})
|
||||
logger.info('[gateway-autostart] gateway stopped profile=%s home=%s', profile, profileDir)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[gateway-autostart] Hermes CLI gateway stop failed before restart profile=%s home=%s', profile, profileDir)
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
|
||||
if (requiresManagedGatewayRun()) {
|
||||
const result = startGatewayRunManaged(hermesBin, { profileDir })
|
||||
logger.info(
|
||||
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
|
||||
@@ -96,6 +178,31 @@ async function startGatewayForProfile(hermesBin: string, profile: string, profil
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGatewayRuntimeStatusForProfile(profile: string): Promise<{ running: boolean; profile: string }> {
|
||||
const hermesBin = resolveHermesBin()
|
||||
const profileDir = getProfileDir(profile)
|
||||
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
|
||||
return { running, profile }
|
||||
}
|
||||
|
||||
export async function restartGatewayForProfile(profile: string): Promise<{ running: boolean; profile: string }> {
|
||||
const hermesBin = resolveHermesBin()
|
||||
const profileDir = getProfileDir(profile)
|
||||
await clearApiServerForProfile(profileDir)
|
||||
await stopGatewayForProfile(hermesBin, profile, profileDir)
|
||||
|
||||
try {
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir)
|
||||
} catch (err) {
|
||||
logger.error(err, '[gateway-autostart] Hermes gateway restart failed profile=%s home=%s', profile, profileDir)
|
||||
throw err
|
||||
}
|
||||
|
||||
const running = await waitForGatewayRunning(hermesBin, profile, profileDir)
|
||||
if (!running) throw new Error('Hermes gateway start completed but gateway did not report running within timeout')
|
||||
return { running, profile }
|
||||
}
|
||||
|
||||
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
|
||||
const configPath = join(profileDir, 'config.yaml')
|
||||
try {
|
||||
@@ -111,15 +218,34 @@ export async function clearApiServerForProfile(profileDir: string): Promise<void
|
||||
export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||
const hermesBin = resolveHermesBin()
|
||||
const profiles = listProfileNamesFromDisk()
|
||||
let gatewayStatuses: Map<string, string> | undefined
|
||||
try {
|
||||
gatewayStatuses = await listGatewayStatusesFromProfileList(hermesBin)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[gateway-autostart] Hermes profile list failed; falling back to per-profile gateway status checks')
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (isReservedProfileName(profile)) {
|
||||
logger.warn('[gateway-autostart] skipping reserved profile name during gateway autostart profile=%s', profile)
|
||||
continue
|
||||
}
|
||||
|
||||
const profileDir = getProfileDir(profile)
|
||||
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
|
||||
const status = gatewayStatuses?.get(profile)
|
||||
const running = status !== undefined
|
||||
? gatewayStatusLooksRunning(status)
|
||||
: await isGatewayRunningForProfile(hermesBin, profileDir)
|
||||
if (running) {
|
||||
logger.info('[gateway-autostart] gateway already running profile=%s home=%s', profile, profileDir)
|
||||
logger.info('[gateway-autostart] gateway already running profile=%s home=%s status=%s', profile, profileDir, status || 'status-check')
|
||||
continue
|
||||
}
|
||||
|
||||
await clearApiServerForProfile(profileDir)
|
||||
await startGatewayForProfile(hermesBin, profile, profileDir)
|
||||
const ready = await waitForGatewayRunning(hermesBin, profile, profileDir)
|
||||
if (!ready) {
|
||||
logger.warn('[gateway-autostart] gateway start completed but did not report running within timeout profile=%s home=%s', profile, profileDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +568,7 @@ export interface HermesProfile {
|
||||
name: string
|
||||
active: boolean
|
||||
model: string
|
||||
gatewayStatus?: string
|
||||
alias: string
|
||||
}
|
||||
|
||||
@@ -598,15 +599,19 @@ export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
|
||||
// Skip header lines (starts with " Profile" or " ─")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(' Profile') || line.match(/^ ─/)) continue
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue
|
||||
|
||||
const match = line.match(/^\s+(◆)?(.+?)\s+(\S+)\s{2,}(\S+)\s{2,}(.*)$/)
|
||||
if (match) {
|
||||
const active = trimmed.startsWith('◆')
|
||||
const body = active ? trimmed.slice(1).trim() : trimmed
|
||||
const columns = body.split(/\s{2,}/).map(part => part.trim())
|
||||
if (columns.length >= 2) {
|
||||
profiles.push({
|
||||
name: match[2],
|
||||
active: !!match[1],
|
||||
model: match[3],
|
||||
alias: match[5].trim() === '—' ? '' : match[5].trim(),
|
||||
name: columns[0],
|
||||
active,
|
||||
model: columns[1] || '—',
|
||||
gatewayStatus: columns[2] && columns[2] !== '—' ? columns[2] : undefined,
|
||||
alias: columns[3] && columns[3] !== '—' ? columns[3] : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user