Fix profile list column parsing (#897)
This commit is contained in:
@@ -7,6 +7,7 @@ import { logger } from '../logger'
|
||||
import { safeFileStore } from '../safe-file-store'
|
||||
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
|
||||
import { startGatewayRunManaged } from './gateway-runner'
|
||||
import { parseGatewayStatusesFromProfileList } from './profile-list-parser'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -109,21 +110,8 @@ export function gatewayStateLooksRunningForProfile(profileDir: string): boolean
|
||||
return pid !== null && isProcessAlive(pid)
|
||||
}
|
||||
|
||||
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
|
||||
export function parseGatewayStatusesFromProfileListOutput(stdout: string, profileNames = listProfileNamesFromDisk()): Map<string, string> {
|
||||
return parseGatewayStatusesFromProfileList(stdout, profileNames)
|
||||
}
|
||||
|
||||
async function listGatewayStatusesFromProfileList(hermesBin: string): Promise<Map<string, string>> {
|
||||
|
||||
@@ -2,11 +2,13 @@ import { execFile, spawn } from 'child_process'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import YAML from 'js-yaml'
|
||||
import { logger } from '../logger'
|
||||
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
|
||||
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
|
||||
import { getActiveProfileDir, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
|
||||
import { startGatewayRunManaged } from './gateway-runner'
|
||||
import { isGatewayRunningForProfile } from './gateway-autostart'
|
||||
import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -582,45 +584,50 @@ export interface HermesProfileDetail {
|
||||
hasSoulMd: boolean
|
||||
}
|
||||
|
||||
function readProfileDefaultModel(name: string): string {
|
||||
const configPath = join(getProfileDir(name), 'config.yaml')
|
||||
if (!existsSync(configPath)) return '—'
|
||||
try {
|
||||
const config = YAML.load(readFileSync(configPath, 'utf-8'), { json: true }) as Record<string, any> | null
|
||||
const model = config?.model
|
||||
if (typeof model === 'string') return model.trim() || '—'
|
||||
if (model && typeof model === 'object') {
|
||||
return String(model.default || '').trim() || '—'
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes CLI: failed to read profile config model for %s', name)
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* List all profiles
|
||||
*/
|
||||
export async function listProfiles(): Promise<HermesProfile[]> {
|
||||
const profileNames = listProfileNamesFromDisk()
|
||||
const activeProfileName = getActiveProfileName()
|
||||
let runtimeInfo = new Map<string, ProfileListRuntimeInfo>()
|
||||
try {
|
||||
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
|
||||
timeout: 10000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
// Windows 可能使用 \r\n 换行符,统一处理
|
||||
const normalized = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
const lines = normalized.trim().split('\n').filter(Boolean)
|
||||
const profiles: HermesProfile[] = []
|
||||
|
||||
// Skip header lines (starts with " Profile" or " ─")
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('Profile') || trimmed.match(/^─/)) continue
|
||||
|
||||
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: columns[0],
|
||||
active,
|
||||
model: columns[1] || '—',
|
||||
gatewayStatus: columns[2] && columns[2] !== '—' ? columns[2] : undefined,
|
||||
alias: columns[3] && columns[3] !== '—' ? columns[3] : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return profiles
|
||||
runtimeInfo = parseProfileListRuntimeInfo(stdout, profileNames)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: profile list failed')
|
||||
throw new Error(`Failed to list profiles: ${err.message}`)
|
||||
logger.warn(err, 'Hermes CLI: profile list failed; falling back to disk profile list')
|
||||
}
|
||||
|
||||
return profileNames.map(name => {
|
||||
const runtime = runtimeInfo.get(name)
|
||||
const gatewayStatus = runtime?.gatewayStatus
|
||||
return {
|
||||
name,
|
||||
active: runtime?.active ?? name === activeProfileName,
|
||||
model: readProfileDefaultModel(name),
|
||||
gatewayStatus: gatewayStatus && gatewayStatus !== '—' && gatewayStatus !== '-' ? gatewayStatus : undefined,
|
||||
alias: runtime?.alias || '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface ProfileListRuntimeInfo {
|
||||
active: boolean
|
||||
gatewayStatus?: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
const GATEWAY_STATUS_TOKENS = new Set([
|
||||
'running',
|
||||
'stopped',
|
||||
'starting',
|
||||
'active',
|
||||
'stop',
|
||||
'—',
|
||||
'-',
|
||||
])
|
||||
|
||||
function normalizeProfileLine(line: string): { active: boolean; body: string } | null {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('Profile') || trimmed.match(/^─/)) return null
|
||||
const active = trimmed.startsWith('◆')
|
||||
return {
|
||||
active,
|
||||
body: active ? trimmed.slice(1).trim() : trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
function matchProfileLine(body: string, profileNames: string[]): { profile: string; rest: string } | null {
|
||||
for (const profile of profileNames) {
|
||||
if (body === profile) return { profile, rest: '' }
|
||||
if (body.startsWith(profile) && /\s/.test(body.charAt(profile.length))) {
|
||||
return { profile, rest: body.slice(profile.length).trim() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractGatewayInfo(rest: string): { gatewayStatus?: string; alias?: string } {
|
||||
const parts = rest.split(/\s+/).filter(Boolean)
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const token = parts[i]
|
||||
if (GATEWAY_STATUS_TOKENS.has(token.toLowerCase())) {
|
||||
const alias = parts[i + 1]
|
||||
return {
|
||||
gatewayStatus: token,
|
||||
alias: alias && alias !== '—' && alias !== '-' ? alias : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function parseProfileListRuntimeInfo(stdout: string, profileNames: string[]): Map<string, ProfileListRuntimeInfo> {
|
||||
const result = new Map<string, ProfileListRuntimeInfo>()
|
||||
const sortedProfiles = [...new Set(profileNames.map(name => name.trim()).filter(Boolean))]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
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 parsed = normalizeProfileLine(line)
|
||||
if (!parsed) continue
|
||||
const matched = matchProfileLine(parsed.body, sortedProfiles)
|
||||
if (!matched) continue
|
||||
const gateway = extractGatewayInfo(matched.rest)
|
||||
result.set(matched.profile, {
|
||||
active: parsed.active,
|
||||
...gateway,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseGatewayStatusesFromProfileList(stdout: string, profileNames: string[]): Map<string, string> {
|
||||
const runtimes = parseProfileListRuntimeInfo(stdout, profileNames)
|
||||
const statuses = new Map<string, string>()
|
||||
for (const [profile, info] of runtimes) {
|
||||
if (info.gatewayStatus) statuses.set(profile, info.gatewayStatus)
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
@@ -30,12 +30,24 @@ describe('gateway autostart status parsing', () => {
|
||||
akri glm-5-turbo running akri —
|
||||
tester gpt-5.5 stopped tester —
|
||||
`
|
||||
const statuses = parseGatewayStatusesFromProfileListOutput(output)
|
||||
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['default', 'akri', 'tester'])
|
||||
expect(statuses.get('default')).toBe('running')
|
||||
expect(statuses.get('akri')).toBe('running')
|
||||
expect(statuses.get('tester')).toBe('stopped')
|
||||
})
|
||||
|
||||
it('parses gateway status when profile or model fills the table column', () => {
|
||||
const output = `
|
||||
Profile Model Gateway Alias Distribution
|
||||
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
|
||||
daily_assistant deepseek-v4-flash running — —
|
||||
long_model provider/model-name-that-fills-column stopped — —
|
||||
`
|
||||
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['daily_assistant', 'long_model'])
|
||||
expect(statuses.get('daily_assistant')).toBe('running')
|
||||
expect(statuses.get('long_model')).toBe('stopped')
|
||||
})
|
||||
|
||||
it('uses profile-list gateway status text for running checks', () => {
|
||||
expect(gatewayStatusLooksRunning('running')).toBe(true)
|
||||
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user