fix false missing Hermes CLI detection after deployment
This commit is contained in:
@@ -33,6 +33,23 @@ npm run build
|
|||||||
- `npm run test:e2e` runs Playwright browser tests against a mocked BFF API.
|
- `npm run test:e2e` runs Playwright browser tests against a mocked BFF API.
|
||||||
- `npm run build` type-checks and builds both client and server.
|
- `npm run build` type-checks and builds both client and server.
|
||||||
|
|
||||||
|
## Hermes CLI Detection
|
||||||
|
|
||||||
|
The installation probe runs `hermes --help` in the Web UI service environment.
|
||||||
|
Some Hermes versions synchronously check upstream updates during `--version`, so
|
||||||
|
version output is not a reliable installation/liveness probe. A timeout does not
|
||||||
|
mean Hermes is absent and must not send users to the installation guide.
|
||||||
|
|
||||||
|
`GET /api/hermes/cli-status` reports `available`, `bin`, and `reason` (`ready`,
|
||||||
|
`missing`, `permission_denied`, `timeout`, or `failed`). `?refresh=1` bypasses the
|
||||||
|
completed-result cache while sharing an in-flight probe. Deploy validation must
|
||||||
|
check this endpoint as well as HTTP health; a running Node process alone does not
|
||||||
|
prove that its service user can execute Hermes.
|
||||||
|
|
||||||
|
When investigating, reproduce with the service's `HERMES_BIN`, `PATH`, `HOME`, and
|
||||||
|
`HERMES_HOME`. Inspect the complete launcher/interpreter path before changing
|
||||||
|
permissions. Do not use recursive permission changes to treat a probe timeout.
|
||||||
|
|
||||||
## One-Click Deploy
|
## One-Click Deploy
|
||||||
|
|
||||||
`deploy.sh` deploys a checkout to a host that runs the built server under
|
`deploy.sh` deploys a checkout to a host that runs the built server under
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ GIT_PROXY="${HERMES_UI_GIT_PROXY:-http://127.0.0.1:7890}"
|
|||||||
|
|
||||||
HEALTH_URL="http://127.0.0.1:$PORT/health"
|
HEALTH_URL="http://127.0.0.1:$PORT/health"
|
||||||
AUTH_URL="http://127.0.0.1:$PORT/api/auth/status"
|
AUTH_URL="http://127.0.0.1:$PORT/api/auth/status"
|
||||||
|
CLI_URL="http://127.0.0.1:$PORT/api/hermes/cli-status?refresh=1"
|
||||||
|
|
||||||
# --- Output helpers -------------------------------------------------------
|
# --- Output helpers -------------------------------------------------------
|
||||||
|
|
||||||
@@ -390,6 +391,22 @@ if [ "$auth_code" != "200" ]; then
|
|||||||
die "Deploy finished but the second health check failed."
|
die "Deploy finished but the second health check failed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Probe from the running service so its user, PATH and HERMES_BIN are authoritative.
|
||||||
|
step "Checking Hermes CLI in the service environment"
|
||||||
|
if ! cli_status="$(curl -fsS --connect-timeout 5 --max-time 25 "$CLI_URL")"; then
|
||||||
|
die "Deploy finished but the Hermes CLI probe could not be reached. Check the service logs."
|
||||||
|
fi
|
||||||
|
if ! node -e '
|
||||||
|
const result = JSON.parse(process.argv[1]);
|
||||||
|
if (result.available !== true) {
|
||||||
|
console.error("Hermes CLI check failed: " + (result.reason || "unknown") + " (" + (result.bin || "hermes") + ")");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
' "$cli_status"; then
|
||||||
|
die "Deploy finished but Hermes CLI is not ready. Check HERMES_BIN and the service logs."
|
||||||
|
fi
|
||||||
|
ok "Hermes CLI is executable by the service"
|
||||||
|
|
||||||
ok "health $HEALTH_URL -> 200"
|
ok "health $HEALTH_URL -> 200"
|
||||||
ok "auth $AUTH_URL -> 200"
|
ok "auth $AUTH_URL -> 200"
|
||||||
ok "Deployed $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown commit') on port $PORT"
|
ok "Deployed $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown commit') on port $PORT"
|
||||||
|
|||||||
+14
-1
@@ -2698,7 +2698,20 @@
|
|||||||
"404": {
|
"404": {
|
||||||
"description": "Not found"
|
"description": "Not found"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "refresh",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/hermes/config": {
|
"/api/hermes/config": {
|
||||||
|
|||||||
@@ -144,30 +144,38 @@ export async function checkHealth(): Promise<HealthResponse> {
|
|||||||
export interface HermesCliStatus {
|
export interface HermesCliStatus {
|
||||||
available: boolean
|
available: boolean
|
||||||
bin?: string
|
bin?: string
|
||||||
|
reason?: 'ready' | 'missing' | 'permission_denied' | 'timeout' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliProbePromise: Promise<boolean> | null = null
|
let cliProbePromise: Promise<HermesCliStatus> | null = null
|
||||||
|
let refreshCliProbe = false
|
||||||
|
|
||||||
async function probeHermesCli(): Promise<boolean> {
|
async function probeHermesCli(refresh: boolean): Promise<HermesCliStatus> {
|
||||||
try {
|
try {
|
||||||
const res = await request<HermesCliStatus>('/api/hermes/cli-status')
|
return await request<HermesCliStatus>(`/api/hermes/cli-status${refresh ? '?refresh=1' : ''}`)
|
||||||
return res.available !== false
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fail open: probe errors (older servers, mocked backends) must not block the app.
|
// Probe transport errors must not block access to the rest of the application.
|
||||||
return true
|
return { available: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cached per page load; the router guard probes once before showing the app. */
|
export function getHermesCliStatus(): Promise<HermesCliStatus> {
|
||||||
export function checkHermesCliAvailable(): Promise<boolean> {
|
|
||||||
if (!cliProbePromise) {
|
if (!cliProbePromise) {
|
||||||
cliProbePromise = probeHermesCli()
|
cliProbePromise = probeHermesCli(refreshCliProbe)
|
||||||
|
refreshCliProbe = false
|
||||||
}
|
}
|
||||||
return cliProbePromise
|
return cliProbePromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A slow startup is not evidence that Hermes needs installing. */
|
||||||
|
export async function checkHermesCliAvailable(): Promise<boolean> {
|
||||||
|
const status = await getHermesCliStatus()
|
||||||
|
return status.available !== false || status.reason === 'timeout'
|
||||||
|
}
|
||||||
|
|
||||||
export function resetHermesCliProbe(): void {
|
export function resetHermesCliProbe(): void {
|
||||||
cliProbePromise = null
|
cliProbePromise = null
|
||||||
|
refreshCliProbe = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function triggerUpdate(): Promise<{ success: boolean; message: string }> {
|
export async function triggerUpdate(): Promise<{ success: boolean; message: string }> {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export default {
|
|||||||
|
|
||||||
// hermes CLI missing guide
|
// hermes CLI missing guide
|
||||||
hermesMissing: {
|
hermesMissing: {
|
||||||
|
repairHint: 'After fixing the executable or runtime, use Re-check below.',
|
||||||
|
permissionTitle: 'Hermes CLI cannot be executed',
|
||||||
|
permissionIntro: 'The service cannot execute Hermes or its Python interpreter. Check executable permissions and directory access for the service user.',
|
||||||
|
failedTitle: 'Hermes CLI failed to start',
|
||||||
|
failedIntro: 'Hermes was found but its startup check failed. Check the service logs and the Hermes Python environment.',
|
||||||
|
|
||||||
title: 'Hermes CLI not detected',
|
title: 'Hermes CLI not detected',
|
||||||
intro: 'hermes-ui no longer bundles the hermes CLI. Install it on the machine running this service first:',
|
intro: 'hermes-ui no longer bundles the hermes CLI. Install it on the machine running this service first:',
|
||||||
installCmd: 'pip install hermes-agent',
|
installCmd: 'pip install hermes-agent',
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export default {
|
|||||||
|
|
||||||
// hermes CLI 缺失引导
|
// hermes CLI 缺失引导
|
||||||
hermesMissing: {
|
hermesMissing: {
|
||||||
|
repairHint: '修复可执行文件或运行环境后,点击下方按钮重新检测。',
|
||||||
|
permissionTitle: 'Hermes CLI 执行权限不足',
|
||||||
|
permissionIntro: '服务无法执行 Hermes 或其 Python 解释器,请检查服务用户的文件执行权限和目录访问权限。',
|
||||||
|
failedTitle: 'Hermes CLI 启动失败',
|
||||||
|
failedIntro: '已找到 Hermes,但启动检测失败。请检查服务日志和 Hermes 的 Python 运行环境。',
|
||||||
|
|
||||||
title: '未检测到 hermes CLI',
|
title: '未检测到 hermes CLI',
|
||||||
intro: 'hermes-ui 不再内置 hermes CLI,请先在运行本服务的机器上安装:',
|
intro: 'hermes-ui 不再内置 hermes CLI,请先在运行本服务的机器上安装:',
|
||||||
installCmd: 'pip install hermes-agent',
|
installCmd: 'pip install hermes-agent',
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { checkHermesCliAvailable, resetHermesCliProbe } from "@/api/hermes/system";
|
import { getHermesCliStatus, resetHermesCliProbe, type HermesCliStatus } from "@/api/hermes/system";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const rechecking = ref(false);
|
const rechecking = ref(false);
|
||||||
|
const status = ref<HermesCliStatus | null>(null);
|
||||||
|
const issue = computed(() => status.value?.reason === 'permission_denied' ? 'permission' : status.value?.reason === 'failed' ? 'failed' : 'missing');
|
||||||
|
const title = computed(() => t(issue.value === 'missing' ? 'hermesMissing.title' : `hermesMissing.${issue.value}Title`));
|
||||||
|
const intro = computed(() => t(issue.value === 'missing' ? 'hermesMissing.intro' : `hermesMissing.${issue.value}Intro`));
|
||||||
|
onMounted(async () => { status.value = await getHermesCliStatus(); });
|
||||||
|
|
||||||
async function handleRetry() {
|
async function handleRetry() {
|
||||||
rechecking.value = true;
|
rechecking.value = true;
|
||||||
try {
|
try {
|
||||||
resetHermesCliProbe();
|
resetHermesCliProbe();
|
||||||
const available = await checkHermesCliAvailable();
|
status.value = await getHermesCliStatus();
|
||||||
if (available) {
|
if (status.value.available || status.value.reason === 'timeout') {
|
||||||
router.replace("/hermes/chat");
|
router.replace("/hermes/chat");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -27,17 +32,18 @@ async function handleRetry() {
|
|||||||
<div class="hermes-missing-card">
|
<div class="hermes-missing-card">
|
||||||
<header class="hermes-missing-hd">
|
<header class="hermes-missing-hd">
|
||||||
<div class="hermes-missing-mark" aria-hidden="true">⚠</div>
|
<div class="hermes-missing-mark" aria-hidden="true">⚠</div>
|
||||||
<h1 class="hermes-missing-title">{{ t("hermesMissing.title") }}</h1>
|
<h1 class="hermes-missing-title">{{ title }}</h1>
|
||||||
<p class="hermes-missing-desc">{{ t("hermesMissing.intro") }}</p>
|
<p class="hermes-missing-desc">{{ intro }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="hermes-missing-bd">
|
<div class="hermes-missing-bd">
|
||||||
<pre class="hermes-missing-term"><code>{{ t("hermesMissing.installCmd") }}</code></pre>
|
<pre v-if="issue === 'missing'" class="hermes-missing-term"><code>{{ t("hermesMissing.installCmd") }}</code></pre>
|
||||||
|
<pre v-else-if="status?.bin" class="hermes-missing-term"><code>{{ status.bin }}</code></pre>
|
||||||
|
|
||||||
<p class="hermes-missing-desc">{{ t("hermesMissing.envIntro") }}</p>
|
<p class="hermes-missing-desc">{{ t("hermesMissing.envIntro") }}</p>
|
||||||
<pre class="hermes-missing-term"><code>{{ t("hermesMissing.envCmd") }}</code></pre>
|
<pre class="hermes-missing-term"><code>{{ t("hermesMissing.envCmd") }}</code></pre>
|
||||||
|
|
||||||
<p class="hermes-missing-hint">{{ t("hermesMissing.restartHint") }}</p>
|
<p class="hermes-missing-hint">{{ t(issue === 'missing' ? "hermesMissing.restartHint" : "hermesMissing.repairHint") }}</p>
|
||||||
|
|
||||||
<button class="hermes-missing-btn" :disabled="rechecking" @click="handleRetry">
|
<button class="hermes-missing-btn" :disabled="rechecking" @click="handleRetry">
|
||||||
{{ rechecking ? "..." : t("hermesMissing.retry") }}
|
{{ rechecking ? "..." : t("hermesMissing.retry") }}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import * as hermesCli from '../services/hermes/hermes-cli'
|
|||||||
import { getAgentBridgeManager } from '../services/hermes/agent-bridge/manager'
|
import { getAgentBridgeManager } from '../services/hermes/agent-bridge/manager'
|
||||||
import { redactAgentBridgeError } from '../services/hermes/agent-bridge/redact'
|
import { redactAgentBridgeError } from '../services/hermes/agent-bridge/redact'
|
||||||
import { isDockerContainer } from '../services/runtime-environment'
|
import { isDockerContainer } from '../services/runtime-environment'
|
||||||
import { isHermesCliAvailable, resolveHermesBin } from '../services/hermes/hermes-process'
|
import { probeHermesCli, resolveHermesBin, type HermesCliProbeResult } from '../services/hermes/hermes-process'
|
||||||
|
|
||||||
|
import { logger } from '../services/logger'
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
|
|
||||||
@@ -209,19 +211,27 @@ export async function healthCheck(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedCliStatus: { value: boolean; expiresAt: number } | null = null
|
let cachedCliStatus: { bin: string; value: HermesCliProbeResult; expiresAt: number } | null = null
|
||||||
|
let pendingCliStatus: { bin: string; promise: Promise<HermesCliProbeResult> } | null = null
|
||||||
const CLI_STATUS_CACHE_TTL_MS = 30_000
|
const CLI_STATUS_CACHE_TTL_MS = 30_000
|
||||||
|
const CLI_FAILURE_CACHE_TTL_MS = 5_000
|
||||||
|
|
||||||
/**
|
/** Public installation probe. Slow startup is distinct from a missing executable. */
|
||||||
* Whether the user-installed hermes CLI can be resolved and executed.
|
|
||||||
* hermes-ui no longer bundles the CLI, so the web UI probes this
|
|
||||||
* (public, pre-login) to show an install guide when it is missing.
|
|
||||||
*/
|
|
||||||
export async function hermesCliStatus(ctx: any) {
|
export async function hermesCliStatus(ctx: any) {
|
||||||
const now = Date.now()
|
const bin = resolveHermesBin()
|
||||||
if (!cachedCliStatus || cachedCliStatus.expiresAt <= now) {
|
if (ctx.query?.refresh !== '1' && cachedCliStatus?.bin === bin && cachedCliStatus.expiresAt > Date.now()) {
|
||||||
const available = await isHermesCliAvailable()
|
ctx.body = { ...cachedCliStatus.value, bin }
|
||||||
cachedCliStatus = { value: available, expiresAt: now + CLI_STATUS_CACHE_TTL_MS }
|
return
|
||||||
}
|
}
|
||||||
ctx.body = { available: cachedCliStatus.value, bin: resolveHermesBin() }
|
if (!pendingCliStatus || pendingCliStatus.bin !== bin) {
|
||||||
|
const promise = probeHermesCli(bin).then(value => {
|
||||||
|
cachedCliStatus = { bin, value, expiresAt: Date.now() + (value.available ? CLI_STATUS_CACHE_TTL_MS : CLI_FAILURE_CACHE_TTL_MS) }
|
||||||
|
if (!value.available) logger.warn({ bin, reason: value.reason }, 'Hermes CLI availability probe failed')
|
||||||
|
return value
|
||||||
|
}).finally(() => {
|
||||||
|
if (pendingCliStatus?.promise === promise) pendingCliStatus = null
|
||||||
|
})
|
||||||
|
pendingCliStatus = { bin, promise }
|
||||||
|
}
|
||||||
|
ctx.body = { ...await pendingCliStatus.promise, bin }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,20 +70,37 @@ export function execHermes(args: readonly string[], options?: ExecFileOptions) {
|
|||||||
return execHermesWithBin(resolveHermesBin(), args, options)
|
return execHermesWithBin(resolveHermesBin(), args, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type HermesCliProbeReason = 'ready' | 'missing' | 'permission_denied' | 'timeout' | 'failed'
|
||||||
* Probe whether the user-installed hermes CLI is reachable.
|
|
||||||
* hermes-ui no longer bundles the CLI, so startup/UI can guide the
|
export interface HermesCliProbeResult {
|
||||||
* user to install it instead of failing on every spawn.
|
available: boolean
|
||||||
*/
|
reason: HermesCliProbeReason
|
||||||
export async function isHermesCliAvailable(hermesBin = resolveHermesBin()): Promise<boolean> {
|
}
|
||||||
|
|
||||||
|
/** Use local help: --version may synchronously check upstream updates. */
|
||||||
|
export async function probeHermesCli(hermesBin = resolveHermesBin()): Promise<HermesCliProbeResult> {
|
||||||
try {
|
try {
|
||||||
await execHermesWithBin(hermesBin, ['--version'], { timeout: 5000 })
|
await execHermesWithBin(hermesBin, ['--help'], { timeout: 15_000 })
|
||||||
return true
|
return { available: true, reason: 'ready' }
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
return false
|
const code = error?.code
|
||||||
|
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 127) {
|
||||||
|
return { available: false, reason: 'missing' }
|
||||||
|
}
|
||||||
|
if (code === 'EACCES' || code === 'EPERM' || code === 126) {
|
||||||
|
return { available: false, reason: 'permission_denied' }
|
||||||
|
}
|
||||||
|
if (code === 'ETIMEDOUT' || (error?.killed && error?.signal === 'SIGTERM')) {
|
||||||
|
return { available: false, reason: 'timeout' }
|
||||||
|
}
|
||||||
|
return { available: false, reason: 'failed' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isHermesCliAvailable(hermesBin = resolveHermesBin()): Promise<boolean> {
|
||||||
|
return (await probeHermesCli(hermesBin)).available
|
||||||
|
}
|
||||||
|
|
||||||
export function spawnHermesWithBin(
|
export function spawnHermesWithBin(
|
||||||
hermesBin: string,
|
hermesBin: string,
|
||||||
args: readonly string[],
|
args: readonly string[],
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
const request = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/api/client', () => ({ request }))
|
||||||
|
|
||||||
|
beforeEach(() => { vi.resetModules(); request.mockReset() })
|
||||||
|
|
||||||
|
describe('CLI installation guard', () => {
|
||||||
|
it('does not send a slow, installed CLI to the installation page', async () => {
|
||||||
|
request.mockResolvedValue({ available: false, reason: 'timeout' })
|
||||||
|
const api = await import('@/api/hermes/system')
|
||||||
|
expect(await api.checkHermesCliAvailable()).toBe(true)
|
||||||
|
expect(await api.getHermesCliStatus()).toMatchObject({ reason: 'timeout' })
|
||||||
|
expect(request).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['missing', 'permission_denied', 'failed'])('retains a genuine %s error and bypasses server cache on retry', async reason => {
|
||||||
|
request.mockResolvedValueOnce({ available: false, reason }).mockResolvedValueOnce({ available: true, reason: 'ready' })
|
||||||
|
const api = await import('@/api/hermes/system')
|
||||||
|
expect(await api.checkHermesCliAvailable()).toBe(false)
|
||||||
|
api.resetHermesCliProbe()
|
||||||
|
expect(await api.checkHermesCliAvailable()).toBe(true)
|
||||||
|
expect(request).toHaveBeenLastCalledWith('/api/hermes/cli-status?refresh=1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues to work with older servers and transport errors', async () => {
|
||||||
|
const api = await import('@/api/hermes/system')
|
||||||
|
request.mockResolvedValueOnce({ available: false })
|
||||||
|
expect(await api.checkHermesCliAvailable()).toBe(false)
|
||||||
|
api.resetHermesCliProbe()
|
||||||
|
request.mockRejectedValueOnce(new Error('server restarting'))
|
||||||
|
expect(await api.checkHermesCliAvailable()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { mockHermesApi } from './fixtures'
|
||||||
|
|
||||||
|
test('a CLI timeout does not redirect to the installation guide', async ({ page }) => {
|
||||||
|
await mockHermesApi(page)
|
||||||
|
await page.route(url => url.pathname === '/api/hermes/cli-status', route => route.fulfill({
|
||||||
|
json: { available: false, reason: 'timeout', bin: '/opt/hermes/bin/hermes' },
|
||||||
|
}))
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByPlaceholder('Username')).toBeVisible()
|
||||||
|
await expect(page.getByText('pip install hermes-agent')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [reason, title] of [
|
||||||
|
['permission_denied', 'Hermes CLI cannot be executed'],
|
||||||
|
['failed', 'Hermes CLI failed to start'],
|
||||||
|
['missing', 'Hermes CLI not detected'],
|
||||||
|
]) {
|
||||||
|
test(`explains ${reason} and retries without waiting for the server cache`, async ({ page }) => {
|
||||||
|
await mockHermesApi(page)
|
||||||
|
let forced = false
|
||||||
|
await page.route(url => url.pathname === '/api/hermes/cli-status', async route => {
|
||||||
|
forced = new URL(route.request().url()).searchParams.get('refresh') === '1'
|
||||||
|
await route.fulfill({ json: { available: forced, reason: forced ? 'ready' : reason, bin: '/opt/hermes/bin/hermes' } })
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible()
|
||||||
|
if (reason !== 'missing') await expect(page.getByText('pip install hermes-agent')).toHaveCount(0)
|
||||||
|
await page.getByRole('button', { name: 'Re-check' }).click()
|
||||||
|
await expect(page.getByPlaceholder('Username')).toBeVisible()
|
||||||
|
expect(forced).toBe(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { dirname, join, resolve } from 'path'
|
||||||
|
import { spawnSync } from 'child_process'
|
||||||
|
|
||||||
|
const dirs: string[] = []
|
||||||
|
afterEach(() => dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true })))
|
||||||
|
|
||||||
|
describe.skipIf(process.platform === 'win32')('deployment CLI readiness', () => {
|
||||||
|
it.each([true, false])('requires executable CLI readiness even when HTTP checks pass: %s', available => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'hermes-deploy-test-'))
|
||||||
|
dirs.push(root)
|
||||||
|
const bin = join(root, 'bin')
|
||||||
|
mkdirSync(bin)
|
||||||
|
const shim = (name: string, body: string) => writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 })
|
||||||
|
shim('npm', 'if [ "$1" = "-v" ]; then echo 11; fi\nexit 0')
|
||||||
|
shim('systemctl', 'exit 0')
|
||||||
|
shim('curl', `case "$*" in\n *cli-status*) echo '${JSON.stringify({ available, reason: available ? 'ready' : 'permission_denied' })}' ;;\n *) printf 200 ;;\nesac`)
|
||||||
|
const result = spawnSync('bash', [resolve('deploy.sh'), '--skip-pull', '--skip-deps', '--skip-build'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 15000,
|
||||||
|
env: { ...process.env, PATH: [bin, dirname(process.execPath), process.env.PATH].join(':'), HERMES_UI_STATE_DIR: join(root, 'state'), HERMES_UI_DEPLOY_SCOPED: '1' },
|
||||||
|
})
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.status).toBe(available ? 0 : 1)
|
||||||
|
expect(result.stdout + result.stderr).toContain(available ? 'Hermes CLI is executable by the service' : 'permission_denied')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ type LoadHealthControllerOptions = {
|
|||||||
bridgeReadinessError?: Error
|
bridgeReadinessError?: Error
|
||||||
managerError?: Error
|
managerError?: Error
|
||||||
runtimeStateError?: Error
|
runtimeStateError?: Error
|
||||||
|
cliProbe?: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultBridgeReadiness = {
|
const defaultBridgeReadiness = {
|
||||||
@@ -65,6 +66,12 @@ async function loadHealthController(options: LoadHealthControllerOptions = {}) {
|
|||||||
isDockerContainer: () => options.isDocker === true,
|
isDockerContainer: () => options.isDocker === true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const cliProbe = options.cliProbe || vi.fn().mockResolvedValue({ available: true, reason: 'ready' })
|
||||||
|
vi.doMock('../../packages/server/src/services/hermes/hermes-process', () => ({
|
||||||
|
probeHermesCli: cliProbe,
|
||||||
|
resolveHermesBin: () => '/opt/hermes/bin/hermes',
|
||||||
|
}))
|
||||||
|
|
||||||
const health = await import('../../packages/server/src/controllers/health')
|
const health = await import('../../packages/server/src/controllers/health')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -303,3 +310,43 @@ describe('health controller version metadata', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('CLI status cache', () => {
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.resetModules() })
|
||||||
|
|
||||||
|
it('coalesces concurrent requests and allows an explicit recheck after failure', async () => {
|
||||||
|
let finish!: (value: any) => void
|
||||||
|
const probe = vi.fn().mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||||
|
.mockResolvedValue({ available: true, reason: 'ready' })
|
||||||
|
const { hermesCliStatus } = await loadHealthController({ cliProbe: probe })
|
||||||
|
const first = createMockCtx()
|
||||||
|
const second = createMockCtx()
|
||||||
|
const pending = [hermesCliStatus(first), hermesCliStatus(second)]
|
||||||
|
expect(probe).toHaveBeenCalledOnce()
|
||||||
|
finish({ available: false, reason: 'timeout' })
|
||||||
|
await Promise.all(pending)
|
||||||
|
expect(first.body).toMatchObject({ available: false, reason: 'timeout' })
|
||||||
|
expect(second.body).toEqual(first.body)
|
||||||
|
await hermesCliStatus(createMockCtx())
|
||||||
|
expect(probe).toHaveBeenCalledOnce()
|
||||||
|
const retry = { ...createMockCtx(), query: { refresh: '1' } }
|
||||||
|
await hermesCliStatus(retry)
|
||||||
|
expect(retry.body).toMatchObject({ available: true, reason: 'ready' })
|
||||||
|
expect(probe).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expires failures quickly and starts successful cache lifetime after the probe completes', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const probe = vi.fn().mockResolvedValueOnce({ available: false, reason: 'permission_denied' })
|
||||||
|
.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve({ available: true, reason: 'ready' }), 8000)))
|
||||||
|
const { hermesCliStatus } = await loadHealthController({ cliProbe: probe })
|
||||||
|
await hermesCliStatus(createMockCtx())
|
||||||
|
await vi.advanceTimersByTimeAsync(5001)
|
||||||
|
const pending = hermesCliStatus(createMockCtx())
|
||||||
|
await vi.advanceTimersByTimeAsync(8000)
|
||||||
|
await pending
|
||||||
|
await vi.advanceTimersByTimeAsync(29000)
|
||||||
|
await hermesCliStatus(createMockCtx())
|
||||||
|
expect(probe).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { tmpdir } from 'os'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||||
|
const execResult = vi.hoisted(() => ({ error: null as any, delayMs: 0 }))
|
||||||
const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
|
||||||
|
|
||||||
vi.mock('child_process', () => ({
|
vi.mock('child_process', () => ({
|
||||||
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||||
execFileCalls.push({ command, args, options })
|
execFileCalls.push({ command, args, options })
|
||||||
callback(null, 'ok\n', '')
|
if (execResult.delayMs) setTimeout(() => callback(execResult.error, 'ok\n', ''), execResult.delayMs)
|
||||||
|
else callback(execResult.error, 'ok\n', '')
|
||||||
}),
|
}),
|
||||||
spawn: vi.fn((command: string, args: string[], options: any) => {
|
spawn: vi.fn((command: string, args: string[], options: any) => {
|
||||||
spawnCalls.push({ command, args, options })
|
spawnCalls.push({ command, args, options })
|
||||||
@@ -24,6 +26,9 @@ function setPlatform(platform: NodeJS.Platform): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
execResult.error = null
|
||||||
|
execResult.delayMs = 0
|
||||||
|
vi.useRealTimers()
|
||||||
execFileCalls.length = 0
|
execFileCalls.length = 0
|
||||||
spawnCalls.length = 0
|
spawnCalls.length = 0
|
||||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||||
@@ -122,3 +127,31 @@ describe('Hermes process invocation', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Hermes installation probe', () => {
|
||||||
|
it('uses local help instead of the network-dependent version check and accepts slow startup', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
execResult.delayMs = 7800
|
||||||
|
const { probeHermesCli } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||||
|
const pending = probeHermesCli('/opt/hermes/bin/hermes')
|
||||||
|
await vi.advanceTimersByTimeAsync(7800)
|
||||||
|
await expect(pending).resolves.toEqual({ available: true, reason: 'ready' })
|
||||||
|
expect(execFileCalls[0]).toMatchObject({ args: ['--help'], options: { timeout: 15000 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ code: 'ENOENT' }, 'missing'],
|
||||||
|
[{ code: 'ENOTDIR' }, 'missing'],
|
||||||
|
[{ code: 127 }, 'missing'],
|
||||||
|
[{ code: 'EACCES' }, 'permission_denied'],
|
||||||
|
[{ code: 'EPERM' }, 'permission_denied'],
|
||||||
|
[{ code: 126 }, 'permission_denied'],
|
||||||
|
[{ killed: true, signal: 'SIGTERM' }, 'timeout'],
|
||||||
|
[{ code: 'ETIMEDOUT' }, 'timeout'],
|
||||||
|
[{ code: 1 }, 'failed'],
|
||||||
|
])('distinguishes %j from a missing installation', async (error, reason) => {
|
||||||
|
execResult.error = error
|
||||||
|
const { probeHermesCli } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||||
|
await expect(probeHermesCli('hermes')).resolves.toEqual({ available: false, reason })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user