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') {
|
||||
|
||||
Reference in New Issue
Block a user