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 { safeFileStore } from '../safe-file-store'
|
||||||
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
|
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
|
||||||
import { startGatewayRunManaged } from './gateway-runner'
|
import { startGatewayRunManaged } from './gateway-runner'
|
||||||
|
import { parseGatewayStatusesFromProfileList } from './profile-list-parser'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -109,21 +110,8 @@ export function gatewayStateLooksRunningForProfile(profileDir: string): boolean
|
|||||||
return pid !== null && isProcessAlive(pid)
|
return pid !== null && isProcessAlive(pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseGatewayStatusesFromProfileListOutput(stdout: string): Map<string, string> {
|
export function parseGatewayStatusesFromProfileListOutput(stdout: string, profileNames = listProfileNamesFromDisk()): Map<string, string> {
|
||||||
const statuses = new Map<string, string>()
|
return parseGatewayStatusesFromProfileList(stdout, profileNames)
|
||||||
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>> {
|
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 { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
import YAML from 'js-yaml'
|
||||||
import { logger } from '../logger'
|
import { logger } from '../logger'
|
||||||
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
|
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 { startGatewayRunManaged } from './gateway-runner'
|
||||||
import { isGatewayRunningForProfile } from './gateway-autostart'
|
import { isGatewayRunningForProfile } from './gateway-autostart'
|
||||||
|
import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -582,45 +584,50 @@ export interface HermesProfileDetail {
|
|||||||
hasSoulMd: boolean
|
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
|
* List all profiles
|
||||||
*/
|
*/
|
||||||
export async function listProfiles(): Promise<HermesProfile[]> {
|
export async function listProfiles(): Promise<HermesProfile[]> {
|
||||||
|
const profileNames = listProfileNamesFromDisk()
|
||||||
|
const activeProfileName = getActiveProfileName()
|
||||||
|
let runtimeInfo = new Map<string, ProfileListRuntimeInfo>()
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
|
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
...execOpts,
|
...execOpts,
|
||||||
})
|
})
|
||||||
|
runtimeInfo = parseProfileListRuntimeInfo(stdout, profileNames)
|
||||||
// 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
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(err, 'Hermes CLI: profile list failed')
|
logger.warn(err, 'Hermes CLI: profile list failed; falling back to disk profile list')
|
||||||
throw new Error(`Failed to list profiles: ${err.message}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 —
|
akri glm-5-turbo running akri —
|
||||||
tester gpt-5.5 stopped tester —
|
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('default')).toBe('running')
|
||||||
expect(statuses.get('akri')).toBe('running')
|
expect(statuses.get('akri')).toBe('running')
|
||||||
expect(statuses.get('tester')).toBe('stopped')
|
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', () => {
|
it('uses profile-list gateway status text for running checks', () => {
|
||||||
expect(gatewayStatusLooksRunning('running')).toBe(true)
|
expect(gatewayStatusLooksRunning('running')).toBe(true)
|
||||||
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user