Improve profile runtime controls (#868)
* Improve profile runtime controls * Restore profile selector test id * Update profile switch e2e flow
This commit is contained in:
@@ -4,6 +4,7 @@ export interface HermesProfile {
|
|||||||
name: string
|
name: string
|
||||||
active: boolean
|
active: boolean
|
||||||
model: string
|
model: string
|
||||||
|
gatewayStatus?: string
|
||||||
alias: string
|
alias: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +18,31 @@ export interface HermesProfileDetail {
|
|||||||
hasSoulMd: boolean
|
hasSoulMd: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileRuntimeStatus {
|
||||||
|
profile: string
|
||||||
|
bridge: {
|
||||||
|
running: boolean
|
||||||
|
profile: string
|
||||||
|
mode?: string
|
||||||
|
reachable?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
gateway: {
|
||||||
|
profile: string
|
||||||
|
running: boolean
|
||||||
|
pid?: number
|
||||||
|
port?: number
|
||||||
|
host?: string
|
||||||
|
url?: string
|
||||||
|
error?: string
|
||||||
|
diagnostics?: {
|
||||||
|
health_url?: string
|
||||||
|
reason?: string
|
||||||
|
health_ok?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProfiles(): Promise<HermesProfile[]> {
|
export async function fetchProfiles(): Promise<HermesProfile[]> {
|
||||||
const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles')
|
const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles')
|
||||||
return res.profiles
|
return res.profiles
|
||||||
@@ -27,6 +53,31 @@ export async function fetchProfileDetail(name: string): Promise<HermesProfileDet
|
|||||||
return res.profile
|
return res.profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProfileRuntimeStatus(name: string): Promise<ProfileRuntimeStatus> {
|
||||||
|
return request<ProfileRuntimeStatus>(`/api/hermes/profiles/${encodeURIComponent(name)}/runtime-status`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfileRuntimeStatuses(): Promise<ProfileRuntimeStatus[]> {
|
||||||
|
const res = await request<{ profiles: ProfileRuntimeStatus[] }>('/api/hermes/profiles/runtime-statuses')
|
||||||
|
return res.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartProfileGateway(name: string): Promise<ProfileRuntimeStatus['gateway']> {
|
||||||
|
const res = await request<{ success: boolean; gateway: ProfileRuntimeStatus['gateway'] }>(
|
||||||
|
`/api/hermes/profiles/${encodeURIComponent(name)}/gateway/restart`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
return res.gateway
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartProfileRuntime(name: string): Promise<ProfileRuntimeStatus> {
|
||||||
|
const res = await request<{ success: boolean; status: ProfileRuntimeStatus }>(
|
||||||
|
`/api/hermes/profiles/${encodeURIComponent(name)}/restart`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
return res.status
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProfileResult {
|
export interface CreateProfileResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
/** clone=true 时被清理的独占平台凭据 KEY 名 */
|
/** clone=true 时被清理的独占平台凭据 KEY 名 */
|
||||||
|
|||||||
@@ -1,32 +1,104 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { NSelect, useMessage } from 'naive-ui'
|
import { NButton, NModal, NSpin, useMessage } from 'naive-ui'
|
||||||
|
import multiavatar from '@multiavatar/multiavatar'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
|
import {
|
||||||
|
fetchProfileRuntimeStatuses,
|
||||||
|
restartProfileGateway,
|
||||||
|
restartProfileRuntime,
|
||||||
|
type ProfileRuntimeStatus,
|
||||||
|
} from '@/api/hermes/profiles'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
|
|
||||||
const options = computed(() =>
|
|
||||||
profilesStore.profiles.map(p => ({
|
|
||||||
label: p.name,
|
|
||||||
value: p.name,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeName = computed(() => profilesStore.activeProfileName ?? '')
|
const activeName = computed(() => profilesStore.activeProfileName ?? '')
|
||||||
|
const displayName = computed(() => activeName.value || 'default')
|
||||||
|
const avatarSvg = computed(() => multiavatar(displayName.value))
|
||||||
|
const runtimeStatuses = ref<ProfileRuntimeStatus[]>([])
|
||||||
|
const runtimeLoading = ref(false)
|
||||||
|
const showProfileModal = ref(false)
|
||||||
|
const gatewayRestarting = ref<Record<string, boolean>>({})
|
||||||
|
const profileRestarting = ref<Record<string, boolean>>({})
|
||||||
|
const profileSwitching = ref<Record<string, boolean>>({})
|
||||||
|
const statusByProfile = computed(() => new Map(runtimeStatuses.value.map(status => [status.profile, status])))
|
||||||
|
|
||||||
async function handleChange(value: string | number | Array<string | number>) {
|
function avatarFor(name: string) {
|
||||||
if (typeof value === 'string' && value !== activeName.value) {
|
return multiavatar(name || 'default')
|
||||||
const ok = await profilesStore.switchProfile(value)
|
|
||||||
if (ok) {
|
|
||||||
message.success(t('profiles.switchSuccess', { name: value }))
|
|
||||||
// Reload to refresh all profile-dependent data
|
|
||||||
window.location.reload()
|
|
||||||
} else {
|
|
||||||
message.error(t('profiles.switchFailed'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRuntimeStatuses() {
|
||||||
|
runtimeLoading.value = true
|
||||||
|
try {
|
||||||
|
runtimeStatuses.value = await fetchProfileRuntimeStatuses()
|
||||||
|
} catch {
|
||||||
|
runtimeStatuses.value = []
|
||||||
|
} finally {
|
||||||
|
runtimeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProfileModal() {
|
||||||
|
showProfileModal.value = true
|
||||||
|
void loadRuntimeStatuses()
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatewayStatusText(running?: boolean) {
|
||||||
|
return running ? t('profiles.runtime.running') : t('profiles.runtime.stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeStatusText(running?: boolean) {
|
||||||
|
return running ? t('profiles.runtime.active') : t('profiles.runtime.idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestartGateway(name: string) {
|
||||||
|
gatewayRestarting.value = { ...gatewayRestarting.value, [name]: true }
|
||||||
|
try {
|
||||||
|
const gateway = await restartProfileGateway(name)
|
||||||
|
const current = statusByProfile.value.get(name)
|
||||||
|
if (current) {
|
||||||
|
runtimeStatuses.value = runtimeStatuses.value.map(status => (
|
||||||
|
status.profile === name ? { ...status, gateway } : status
|
||||||
|
))
|
||||||
|
}
|
||||||
|
message.success(t('profiles.runtime.gatewayRestarted', { name }))
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('profiles.runtime.gatewayRestartFailed'))
|
||||||
|
} finally {
|
||||||
|
gatewayRestarting.value = { ...gatewayRestarting.value, [name]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestartProfile(name: string) {
|
||||||
|
profileRestarting.value = { ...profileRestarting.value, [name]: true }
|
||||||
|
try {
|
||||||
|
const status = await restartProfileRuntime(name)
|
||||||
|
runtimeStatuses.value = runtimeStatuses.value.map(item => (
|
||||||
|
item.profile === name ? status : item
|
||||||
|
))
|
||||||
|
message.success(t('profiles.runtime.profileRestarted', { name }))
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('profiles.runtime.profileRestartFailed'))
|
||||||
|
} finally {
|
||||||
|
profileRestarting.value = { ...profileRestarting.value, [name]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwitchProfile(name: string) {
|
||||||
|
if (name === displayName.value) return
|
||||||
|
profileSwitching.value = { ...profileSwitching.value, [name]: true }
|
||||||
|
try {
|
||||||
|
const ok = await profilesStore.switchProfile(name)
|
||||||
|
if (!ok) throw new Error(t('profiles.switchFailed'))
|
||||||
|
message.success(t('profiles.switchSuccess', { name }))
|
||||||
|
window.location.reload()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || t('profiles.switchFailed'))
|
||||||
|
} finally {
|
||||||
|
profileSwitching.value = { ...profileSwitching.value, [name]: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +112,97 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="profile-selector">
|
<div class="profile-selector">
|
||||||
<div class="selector-label">{{ t('sidebar.profiles') }}</div>
|
<div class="selector-label">{{ t('sidebar.profiles') }}</div>
|
||||||
<NSelect
|
<div class="profile-display" data-testid="profile-selector-select" @click="openProfileModal">
|
||||||
data-testid="profile-selector-select"
|
<span class="profile-avatar" v-html="avatarSvg" />
|
||||||
:value="activeName"
|
<span class="profile-name">{{ displayName }}</span>
|
||||||
:options="options"
|
</div>
|
||||||
:loading="profilesStore.switching"
|
|
||||||
|
<NModal
|
||||||
|
v-model:show="showProfileModal"
|
||||||
|
preset="card"
|
||||||
|
:bordered="false"
|
||||||
|
:style="{ width: '720px', maxWidth: 'calc(100vw - 32px)' }"
|
||||||
|
class="profile-manager-modal"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="profile-modal-header">
|
||||||
|
<div class="profile-popover-title">
|
||||||
|
<span class="profile-popover-name">{{ t('sidebar.profiles') }}</span>
|
||||||
|
<span class="profile-popover-subtitle">{{ t('profiles.runtime.activeProfile', { name: displayName }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NSpin :show="runtimeLoading" size="small">
|
||||||
|
<div class="profile-runtime-list">
|
||||||
|
<div
|
||||||
|
v-for="profile in profilesStore.profiles"
|
||||||
|
:key="profile.name"
|
||||||
|
class="profile-runtime-item"
|
||||||
|
:class="{ active: profile.name === displayName }"
|
||||||
|
>
|
||||||
|
<div class="profile-runtime-main">
|
||||||
|
<span class="profile-runtime-avatar" v-html="avatarFor(profile.name)" />
|
||||||
|
<div class="profile-runtime-info">
|
||||||
|
<div class="profile-runtime-name-row">
|
||||||
|
<span class="profile-runtime-name">{{ profile.name }}</span>
|
||||||
|
<span v-if="profile.name === displayName" class="active-badge">{{ t('profiles.runtime.activeTag') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="runtime-status-grid">
|
||||||
|
<div class="runtime-row compact">
|
||||||
|
<span class="runtime-label">{{ t('profiles.runtime.bridgeWorker') }}</span>
|
||||||
|
<span class="runtime-value" :class="{ running: statusByProfile.get(profile.name)?.bridge.running }">
|
||||||
|
<span class="runtime-dot" />
|
||||||
|
{{ bridgeStatusText(statusByProfile.get(profile.name)?.bridge.running) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="runtime-row compact">
|
||||||
|
<span class="runtime-label">{{ t('profiles.runtime.gateway') }}</span>
|
||||||
|
<span class="runtime-value" :class="{ running: statusByProfile.get(profile.name)?.gateway.running }">
|
||||||
|
<span class="runtime-dot" />
|
||||||
|
{{ gatewayStatusText(statusByProfile.get(profile.name)?.gateway.running) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!statusByProfile.get(profile.name)?.gateway.running && (statusByProfile.get(profile.name)?.gateway.diagnostics?.reason || statusByProfile.get(profile.name)?.gateway.error)"
|
||||||
|
class="runtime-detail"
|
||||||
|
>
|
||||||
|
{{ statusByProfile.get(profile.name)?.gateway.diagnostics?.reason || statusByProfile.get(profile.name)?.gateway.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-runtime-actions">
|
||||||
|
<NButton
|
||||||
size="small"
|
size="small"
|
||||||
@update:value="handleChange"
|
type="primary"
|
||||||
/>
|
:loading="gatewayRestarting[profile.name]"
|
||||||
|
@click="handleRestartGateway(profile.name)"
|
||||||
|
>
|
||||||
|
{{ t('profiles.runtime.restartGateway') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="profileRestarting[profile.name]"
|
||||||
|
@click="handleRestartProfile(profile.name)"
|
||||||
|
>
|
||||||
|
{{ t('profiles.runtime.restartProfile') }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="profile.name === displayName"
|
||||||
|
:loading="profileSwitching[profile.name]"
|
||||||
|
@click="handleSwitchProfile(profile.name)"
|
||||||
|
>
|
||||||
|
{{ t('profiles.runtime.switchProfile') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,4 +222,232 @@ onMounted(() => {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $bg-secondary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: $bg-card;
|
||||||
|
|
||||||
|
:deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $bg-secondary;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
:deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-popover-subtitle,
|
||||||
|
.runtime-label,
|
||||||
|
.runtime-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 420px;
|
||||||
|
min-height: 96px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $bg-card;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: $accent-muted;
|
||||||
|
background: $bg-card-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: $bg-secondary;
|
||||||
|
|
||||||
|
:deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, $success 16%, transparent);
|
||||||
|
color: $success;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-runtime-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(88px, max-content));
|
||||||
|
justify-content: end;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
:deep(.n-button) {
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.running {
|
||||||
|
color: $success;
|
||||||
|
|
||||||
|
.runtime-dot {
|
||||||
|
background: $success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail {
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -695,6 +695,23 @@ export default {
|
|||||||
hasEnv: 'Has .env',
|
hasEnv: 'Has .env',
|
||||||
hasSoulMd: 'Has soul.md',
|
hasSoulMd: 'Has soul.md',
|
||||||
noProfiles: 'No profiles found. Create one to get started.',
|
noProfiles: 'No profiles found. Create one to get started.',
|
||||||
|
runtime: {
|
||||||
|
activeProfile: 'Active: {name}',
|
||||||
|
bridgeWorker: 'Bridge worker',
|
||||||
|
gateway: 'Gateway',
|
||||||
|
active: 'Active',
|
||||||
|
activeTag: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
running: 'Running',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
restartGateway: 'Restart Gateway',
|
||||||
|
restartProfile: 'Restart Profile',
|
||||||
|
switchProfile: 'Switch Profile',
|
||||||
|
gatewayRestarted: 'Gateway restarted: {name}',
|
||||||
|
gatewayRestartFailed: 'Failed to restart gateway',
|
||||||
|
profileRestarted: 'Profile restarted: {name}',
|
||||||
|
profileRestartFailed: 'Failed to restart profile',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
|
|||||||
@@ -687,6 +687,23 @@ export default {
|
|||||||
hasEnv: '有 .env',
|
hasEnv: '有 .env',
|
||||||
hasSoulMd: '有 soul.md',
|
hasSoulMd: '有 soul.md',
|
||||||
noProfiles: '暂无配置,创建一个开始吧。',
|
noProfiles: '暂无配置,创建一个开始吧。',
|
||||||
|
runtime: {
|
||||||
|
activeProfile: '当前:{name}',
|
||||||
|
bridgeWorker: '桥接状态',
|
||||||
|
gateway: '网关',
|
||||||
|
active: '活跃',
|
||||||
|
activeTag: '当前',
|
||||||
|
idle: '空闲',
|
||||||
|
running: '运行中',
|
||||||
|
stopped: '已停止',
|
||||||
|
restartGateway: '重启网关',
|
||||||
|
restartProfile: '重启配置',
|
||||||
|
switchProfile: '切换配置',
|
||||||
|
gatewayRestarted: '网关已重启:{name}',
|
||||||
|
gatewayRestartFailed: '重启网关失败',
|
||||||
|
profileRestarted: '配置已重启:{name}',
|
||||||
|
profileRestartFailed: '重启配置失败',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { tmpdir } from 'os'
|
|||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
import { SessionDeleter } from '../../services/hermes/session-deleter'
|
import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||||
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
|
||||||
|
import {
|
||||||
|
getGatewayRuntimeStatusForProfile,
|
||||||
|
restartGatewayForProfile as restartGatewayRuntimeForProfile,
|
||||||
|
} from '../../services/hermes/gateway-autostart'
|
||||||
import { logger } from '../../services/logger'
|
import { logger } from '../../services/logger'
|
||||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||||
@@ -84,6 +88,10 @@ function deleteForbiddenProfileFromDisk(name: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
|
||||||
|
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
|
||||||
|
}
|
||||||
|
|
||||||
async function useProfileWithFallback(name: string): Promise<string> {
|
async function useProfileWithFallback(name: string): Promise<string> {
|
||||||
if (isForbiddenProfileName(name)) {
|
if (isForbiddenProfileName(name)) {
|
||||||
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
|
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) {
|
export async function list(ctx: any) {
|
||||||
try {
|
try {
|
||||||
let profiles: HermesProfile[]
|
let profiles: HermesProfile[]
|
||||||
@@ -120,6 +184,8 @@ export async function list(ctx: any) {
|
|||||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||||
const activeProfileName = getActiveProfileName()
|
const activeProfileName = getActiveProfileName()
|
||||||
|
|
||||||
|
profiles = filterVisibleProfiles(profiles)
|
||||||
|
|
||||||
// Check if CLI's active flag matches the file (warn if inconsistent)
|
// Check if CLI's active flag matches the file (warn if inconsistent)
|
||||||
const cliActive = profiles.find(p => p.active)
|
const cliActive = profiles.find(p => p.active)
|
||||||
if (cliActive?.name !== activeProfileName) {
|
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) {
|
export async function remove(ctx: any) {
|
||||||
const { name } = ctx.params
|
const { name } = ctx.params
|
||||||
if (name === 'default') {
|
if (name === 'default') {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export const profileRoutes = new Router()
|
|||||||
|
|
||||||
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
||||||
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
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.get('/api/hermes/profiles/:name', ctrl.get)
|
||||||
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||||
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
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.home() / "hermes-agent",
|
||||||
Path("/opt/hermes/hermes-agent"),
|
Path("/opt/hermes/hermes-agent"),
|
||||||
Path("/opt/hermes-agent"),
|
Path("/opt/hermes-agent"),
|
||||||
|
Path("/usr/local/lib/hermes-agent"),
|
||||||
Path("/usr/local/hermes-agent"),
|
Path("/usr/local/hermes-agent"),
|
||||||
])
|
])
|
||||||
candidates.append(Path(DEFAULT_AGENT_ROOT).expanduser())
|
candidates.append(Path(DEFAULT_AGENT_ROOT).expanduser())
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function agentRootFromHermesBin(): string | undefined {
|
|||||||
resolve(binDir, '..'),
|
resolve(binDir, '..'),
|
||||||
resolve(binDir, '..', '..'),
|
resolve(binDir, '..', '..'),
|
||||||
resolve(binDir, '..', 'hermes-agent'),
|
resolve(binDir, '..', 'hermes-agent'),
|
||||||
|
resolve(binDir, '..', 'lib', 'hermes-agent'),
|
||||||
resolve(binDir, '..', '..', 'hermes-agent'),
|
resolve(binDir, '..', '..', 'hermes-agent'),
|
||||||
]
|
]
|
||||||
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||||
@@ -113,6 +114,7 @@ function agentRootFromHermesBin(): string | undefined {
|
|||||||
const shebangRootCandidates = [
|
const shebangRootCandidates = [
|
||||||
resolve(pyDir, '..', '..'),
|
resolve(pyDir, '..', '..'),
|
||||||
resolve(pyDir, '..', '..', 'hermes-agent'),
|
resolve(pyDir, '..', '..', 'hermes-agent'),
|
||||||
|
resolve(pyDir, '..', '..', 'lib', 'hermes-agent'),
|
||||||
]
|
]
|
||||||
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||||
}
|
}
|
||||||
@@ -155,6 +157,10 @@ function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): s
|
|||||||
agentRootFromHermesBin(),
|
agentRootFromHermesBin(),
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
join(process.cwd(), 'hermes-agent'),
|
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)
|
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||||
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,27 @@ import { startGatewayRunManaged } from './gateway-runner'
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
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 {
|
function resolveHermesBin(): string {
|
||||||
return process.env.HERMES_BIN?.trim() || 'hermes'
|
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 {
|
function isDockerRuntime(): boolean {
|
||||||
return existsSync('/.dockerenv')
|
return existsSync('/.dockerenv')
|
||||||
}
|
}
|
||||||
@@ -25,6 +42,10 @@ function isTermuxRuntime(): boolean {
|
|||||||
existsSync('/data/data/com.termux/files/usr')
|
existsSync('/data/data/com.termux/files/usr')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiresManagedGatewayRun(): boolean {
|
||||||
|
return isDockerRuntime() || isTermuxRuntime() || process.platform === 'win32'
|
||||||
|
}
|
||||||
|
|
||||||
export function gatewayStatusLooksRunning(output: string): boolean {
|
export function gatewayStatusLooksRunning(output: string): boolean {
|
||||||
const text = output.toLowerCase()
|
const text = output.toLowerCase()
|
||||||
if (text.includes('gateway is not running') || text.includes('not running')) return false
|
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')
|
|| 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> {
|
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
|
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> {
|
async function waitForGatewayRunning(hermesBin: string, profile: string, profileDir: string, timeoutMs = 15000): Promise<boolean> {
|
||||||
if (isDockerRuntime() || isTermuxRuntime()) {
|
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 })
|
const result = startGatewayRunManaged(hermesBin, { profileDir })
|
||||||
logger.info(
|
logger.info(
|
||||||
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
|
'[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> {
|
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
|
||||||
const configPath = join(profileDir, 'config.yaml')
|
const configPath = join(profileDir, 'config.yaml')
|
||||||
try {
|
try {
|
||||||
@@ -111,15 +218,34 @@ export async function clearApiServerForProfile(profileDir: string): Promise<void
|
|||||||
export async function ensureProfileGatewaysRunning(): Promise<void> {
|
export async function ensureProfileGatewaysRunning(): Promise<void> {
|
||||||
const hermesBin = resolveHermesBin()
|
const hermesBin = resolveHermesBin()
|
||||||
const profiles = listProfileNamesFromDisk()
|
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) {
|
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 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) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearApiServerForProfile(profileDir)
|
await clearApiServerForProfile(profileDir)
|
||||||
await startGatewayForProfile(hermesBin, profile, 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
|
name: string
|
||||||
active: boolean
|
active: boolean
|
||||||
model: string
|
model: string
|
||||||
|
gatewayStatus?: string
|
||||||
alias: string
|
alias: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,15 +599,19 @@ export async function listProfiles(): Promise<HermesProfile[]> {
|
|||||||
|
|
||||||
// Skip header lines (starts with " Profile" or " ─")
|
// Skip header lines (starts with " Profile" or " ─")
|
||||||
for (const line of lines) {
|
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,}(.*)$/)
|
const active = trimmed.startsWith('◆')
|
||||||
if (match) {
|
const body = active ? trimmed.slice(1).trim() : trimmed
|
||||||
|
const columns = body.split(/\s{2,}/).map(part => part.trim())
|
||||||
|
if (columns.length >= 2) {
|
||||||
profiles.push({
|
profiles.push({
|
||||||
name: match[2],
|
name: columns[0],
|
||||||
active: !!match[1],
|
active,
|
||||||
model: match[3],
|
model: columns[1] || '—',
|
||||||
alias: match[5].trim() === '—' ? '' : match[5].trim(),
|
gatewayStatus: columns[2] && columns[2] !== '—' ? columns[2] : undefined,
|
||||||
|
alias: columns[3] && columns[3] !== '—' ? columns[3] : '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as esbuild from 'esbuild'
|
import * as esbuild from 'esbuild'
|
||||||
import { resolve, dirname } from 'path'
|
import { resolve, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { cpSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
||||||
|
|
||||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
|
const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
|
||||||
@@ -34,6 +34,7 @@ cpSync(
|
|||||||
resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
||||||
resolve(bridgeOutDir, 'hermes_bridge.py'),
|
resolve(bridgeOutDir, 'hermes_bridge.py'),
|
||||||
)
|
)
|
||||||
|
chmodSync(resolve(bridgeOutDir, 'hermes_bridge.py'), 0o755)
|
||||||
|
|
||||||
const skillsOutDir = resolve(rootDir, 'dist/skills')
|
const skillsOutDir = resolve(rootDir, 'dist/skills')
|
||||||
rmSync(skillsOutDir, { recursive: true, force: true })
|
rmSync(skillsOutDir, { recursive: true, force: true })
|
||||||
|
|||||||
@@ -98,9 +98,10 @@ test('uses the newly selected profile for the next chat-run socket after profile
|
|||||||
}, defaultRun.run.session_id)
|
}, defaultRun.run.session_id)
|
||||||
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||||
|
|
||||||
await page.locator('[data-testid="profile-selector-select"] .n-base-selection').click()
|
await page.getByTestId('profile-selector-select').click()
|
||||||
|
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
|
||||||
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
|
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
|
||||||
await page.locator('.n-base-select-option', { hasText: /^research$/ }).click()
|
await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Profile' }).click()
|
||||||
await reloadPromise
|
await reloadPromise
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState('domcontentloaded')
|
||||||
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
|
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
|
||||||
|
|||||||
@@ -159,6 +159,24 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/hermes/profiles/runtime-statuses') {
|
||||||
|
await route.fulfill(jsonResponse({
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
profile: 'default',
|
||||||
|
bridge: { running: activeProfileName === 'default', profile: 'default', reachable: true },
|
||||||
|
gateway: { running: true, profile: 'default' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profile: 'research',
|
||||||
|
bridge: { running: activeProfileName === 'research', profile: 'research', reachable: true },
|
||||||
|
gateway: { running: true, profile: 'research' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/hermes/profiles/active') {
|
if (pathname === '/api/hermes/profiles/active') {
|
||||||
if (request.method() !== 'PUT') {
|
if (request.method() !== 'PUT') {
|
||||||
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
|
||||||
|
|||||||
@@ -48,6 +48,30 @@ describe('agent bridge manager command resolution', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('discovers hermes-agent from a global lib install next to the hermes command', async () => {
|
||||||
|
const installDir = join(tempDir, 'usr', 'local')
|
||||||
|
const binDir = join(installDir, 'bin')
|
||||||
|
const agentRoot = join(installDir, 'lib', 'hermes-agent')
|
||||||
|
const fakePython = join(binDir, 'python')
|
||||||
|
const fakeHermes = join(binDir, 'hermes')
|
||||||
|
const homeDir = join(tempDir, 'home')
|
||||||
|
mkdirSync(binDir, { recursive: true })
|
||||||
|
mkdirSync(agentRoot, { recursive: true })
|
||||||
|
mkdirSync(homeDir, { recursive: true })
|
||||||
|
writeFileSync(join(agentRoot, 'run_agent.py'), '')
|
||||||
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
||||||
|
chmodSync(fakePython, 0o755)
|
||||||
|
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
||||||
|
chmodSync(fakeHermes, 0o755)
|
||||||
|
process.env.HERMES_HOME = homeDir
|
||||||
|
process.env.HERMES_BIN = fakeHermes
|
||||||
|
|
||||||
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
||||||
|
const command = resolveAgentBridgeCommand()
|
||||||
|
|
||||||
|
expect(command.agentRoot).toBe(agentRoot)
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back to system Python instead of uv when no source root exists', async () => {
|
it('falls back to system Python instead of uv when no source root exists', async () => {
|
||||||
const homeDir = join(tempDir, 'home')
|
const homeDir = join(tempDir, 'home')
|
||||||
const fakePython = join(tempDir, 'python3')
|
const fakePython = join(tempDir, 'python3')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
gatewayStatusLooksRuntimeLocked,
|
gatewayStatusLooksRuntimeLocked,
|
||||||
gatewayStatusLooksRunning,
|
gatewayStatusLooksRunning,
|
||||||
|
parseGatewayStatusesFromProfileListOutput,
|
||||||
} from '../../packages/server/src/services/hermes/gateway-autostart'
|
} from '../../packages/server/src/services/hermes/gateway-autostart'
|
||||||
|
|
||||||
describe('gateway autostart status parsing', () => {
|
describe('gateway autostart status parsing', () => {
|
||||||
@@ -14,4 +15,24 @@ describe('gateway autostart status parsing', () => {
|
|||||||
it('does not treat not-running status as running', () => {
|
it('does not treat not-running status as running', () => {
|
||||||
expect(gatewayStatusLooksRunning('Gateway is not running')).toBe(false)
|
expect(gatewayStatusLooksRunning('Gateway is not running')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses gateway status from hermes profile list output', () => {
|
||||||
|
const output = `
|
||||||
|
Profile Model Gateway Alias Distribution
|
||||||
|
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
|
||||||
|
◆default glm-5-turbo running — —
|
||||||
|
akri glm-5-turbo running akri —
|
||||||
|
tester gpt-5.5 stopped tester —
|
||||||
|
`
|
||||||
|
const statuses = parseGatewayStatusesFromProfileListOutput(output)
|
||||||
|
expect(statuses.get('default')).toBe('running')
|
||||||
|
expect(statuses.get('akri')).toBe('running')
|
||||||
|
expect(statuses.get('tester')).toBe('stopped')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses profile-list gateway status text for running checks', () => {
|
||||||
|
expect(gatewayStatusLooksRunning('running')).toBe(true)
|
||||||
|
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
||||||
|
expect(gatewayStatusLooksRunning('not running')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user