feat(#3628): gateway start/stop/restart controls in Settings → System
Adds WebUI controls to manage the messaging gateway lifecycle without a
terminal. _handle_gateway_lifecycle routes start/stop/restart to the
existing platform-aware `hermes gateway {action}` CLI via a list-argv
subprocess (no shell), scoped to the active profile, with bounded
timeout/error handling and a status-card refresh. Auth + CSRF gated;
profile name charset-validated; stdout/stderr never leaked to the client.
Maintainer hardening (warm-up SHOULD-FIX): added _GATEWAY_ACTION_LOCK, a
server-side single-flight guard. The client disables its button in-flight,
but a scripted authed client could otherwise fire overlapping
start/stop/restart calls and spawn concurrent gateway subprocesses. A
non-blocking acquire now returns 409 on contention (mirrors the self-update
_apply_lock pattern), released in finally. Added a regression test
(toothless-checked: without the lock the 2nd action spawns a subprocess +
returns 200).
Cross-platform: delegates to the mature hermes_cli gateway subcommand
(systemctl/launchctl), pathlib paths, PYTHONUTF8=1 + BROWSER=echo in env.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
parent
41d51ecbcf
commit
a41a1bf393
5 changed files with 709 additions and 119 deletions
|
|
@ -3,6 +3,12 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.481] — 2026-06-17 — Release QP (gateway start/stop/restart controls in Settings)
|
||||
|
||||
### Added
|
||||
|
||||
- **Settings → System now has start / stop / restart controls for the messaging gateway (#3628).** Manage the Hermes gateway (Telegram, Discord, Slack, etc.) from the WebUI without dropping to a terminal. The controls call the existing platform-aware `hermes gateway {start,stop,restart}` CLI (systemd on Linux, launchd on macOS), refresh the gateway status card, and surface bounded errors. The endpoint is auth + CSRF gated, validates the active profile name, runs a list-argv subprocess (no shell), and is serialized server-side so overlapping actions return 409 rather than spawning concurrent gateway processes. Thanks @rodboev.
|
||||
|
||||
## [v0.51.480] — 2026-06-17 — Release QO (inline PDF preview renders all pages)
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
295
api/routes.py
295
api/routes.py
|
|
@ -949,6 +949,206 @@ def _load_gateway_session_identity_map() -> dict[str, dict]:
|
|||
return mapping.copy()
|
||||
|
||||
|
||||
def _gateway_status_payload() -> dict:
|
||||
import datetime
|
||||
|
||||
identity_map = _load_gateway_session_identity_map()
|
||||
sessions_path = _gateway_session_metadata_path()
|
||||
|
||||
# Detect whether the gateway process is alive, independent of connected
|
||||
# messaging platforms. An empty identity_map means zero connected
|
||||
# platforms, not necessarily a stopped gateway.
|
||||
health = build_agent_health_payload()
|
||||
alive = health.get("alive")
|
||||
details = health.get("details") if isinstance(health.get("details"), dict) else {}
|
||||
health_reason = details.get("reason")
|
||||
health_state = details.get("state")
|
||||
health_gateway_state = details.get("gateway_state")
|
||||
if alive is True:
|
||||
running = True
|
||||
configured = True
|
||||
elif alive is False:
|
||||
running = False
|
||||
configured = True
|
||||
else:
|
||||
gateway_running_metadata = (
|
||||
health_reason == "gateway_stale_running_state"
|
||||
or health_gateway_state == "running"
|
||||
)
|
||||
configured = True if gateway_running_metadata else bool(identity_map)
|
||||
running = bool(identity_map)
|
||||
|
||||
platforms_set: set[str] = set()
|
||||
for meta in identity_map.values():
|
||||
raw = meta.get("raw_source") or meta.get("platform") or ""
|
||||
norm = _normalize_messaging_source(raw)
|
||||
if norm:
|
||||
platforms_set.add(norm)
|
||||
platform_labels = {
|
||||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"email": "Email",
|
||||
"web": "Web",
|
||||
"api": "API",
|
||||
}
|
||||
platforms = sorted(
|
||||
[{"name": p, "label": platform_labels.get(p, p.title())} for p in platforms_set],
|
||||
key=lambda x: x["label"],
|
||||
)
|
||||
last_active = ""
|
||||
if running and sessions_path.exists():
|
||||
try:
|
||||
mtime = sessions_path.stat().st_mtime
|
||||
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"running": running,
|
||||
"configured": configured,
|
||||
"platforms": platforms,
|
||||
"last_active": last_active,
|
||||
"session_count": len(identity_map),
|
||||
"health": {
|
||||
"state": health_state,
|
||||
"reason": health_reason,
|
||||
"gateway_state": health_gateway_state,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS = 60
|
||||
|
||||
# Server-side single-flight guard for gateway lifecycle actions. The client
|
||||
# disables its button while a request is in flight, but a scripted authed
|
||||
# client could still fire overlapping start/stop/restart calls, spawning
|
||||
# concurrent `hermes gateway` subprocesses. Serialize them here (mirrors the
|
||||
# self-update _apply_lock pattern): a non-blocking acquire returns 409 on
|
||||
# contention rather than launching a second overlapping subprocess.
|
||||
_GATEWAY_ACTION_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _run_gateway_lifecycle_command(action: str) -> subprocess.CompletedProcess:
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
raise ValueError("unsupported gateway action")
|
||||
|
||||
from api import config as api_config
|
||||
from api.profiles import get_active_profile_name
|
||||
|
||||
agent_dir = getattr(api_config, "_AGENT_DIR", None)
|
||||
if not agent_dir:
|
||||
raise FileNotFoundError("Hermes agent checkout not found")
|
||||
agent_dir = Path(agent_dir).expanduser().resolve()
|
||||
main_py = agent_dir / "hermes_cli" / "main.py"
|
||||
if not main_py.exists():
|
||||
raise FileNotFoundError("Hermes agent CLI entrypoint not found")
|
||||
|
||||
cmd = [str(getattr(api_config, "PYTHON_EXE", sys.executable)), str(main_py)]
|
||||
profile_name = ""
|
||||
try:
|
||||
profile_name = str(get_active_profile_name() or "").strip()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve active profile for gateway lifecycle: %s", exc)
|
||||
if profile_name and profile_name != "default":
|
||||
cmd.extend(["--profile", profile_name])
|
||||
cmd.extend(["gateway", action])
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("PYTHONUTF8", "1")
|
||||
env.setdefault("BROWSER", "echo")
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=str(agent_dir),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _handle_gateway_lifecycle(handler, action: str, body: dict):
|
||||
del body # Reserved for future per-gateway naming without changing the route contract.
|
||||
# Reject overlapping lifecycle actions instead of spawning concurrent
|
||||
# `hermes gateway` subprocesses (a non-blocking acquire — the action holds
|
||||
# the lock for at most _GATEWAY_LIFECYCLE_TIMEOUT_SECONDS).
|
||||
if action not in {"start", "stop", "restart"}:
|
||||
return bad(handler, "unsupported gateway action", 400)
|
||||
if not _GATEWAY_ACTION_LOCK.acquire(blocking=False):
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Another gateway action is already in progress; try again shortly.",
|
||||
"action": action,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
try:
|
||||
result = _run_gateway_lifecycle_command(action)
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc), 400)
|
||||
except FileNotFoundError as exc:
|
||||
return j(handler, {"ok": False, "error": _sanitize_error(exc), "action": action}, status=500)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning(
|
||||
"Gateway %s command timed out after %ss; stdout=%r stderr=%r",
|
||||
action,
|
||||
_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS,
|
||||
exc.stdout,
|
||||
exc.stderr,
|
||||
)
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"Gateway {action} timed out after {_GATEWAY_LIFECYCLE_TIMEOUT_SECONDS} seconds",
|
||||
"action": action,
|
||||
},
|
||||
status=504,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Gateway %s command failed before completion", action)
|
||||
return j(handler, {"ok": False, "error": _sanitize_error(exc), "action": action}, status=500)
|
||||
finally:
|
||||
_GATEWAY_ACTION_LOCK.release()
|
||||
|
||||
stdout = (result.stdout or "").strip()
|
||||
stderr = (result.stderr or "").strip()
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"Gateway %s command failed with exit code %s; stdout=%r stderr=%r",
|
||||
action,
|
||||
result.returncode,
|
||||
stdout,
|
||||
stderr,
|
||||
)
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"Gateway {action} failed with exit code {result.returncode}",
|
||||
"action": action,
|
||||
"returncode": result.returncode,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": True,
|
||||
"action": action,
|
||||
# Do NOT return captured stdout/stderr — the `hermes gateway` CLI
|
||||
# prints service/PID/status details the browser shouldn't receive
|
||||
# (mirrors the failure path, which already suppresses them). The
|
||||
# frontend localizes its own success copy; the refreshed status
|
||||
# payload carries the user-facing state.
|
||||
"message": f"Gateway {action} completed.",
|
||||
"status": _gateway_status_payload(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _mark_cron_running(job_id: str):
|
||||
with _RUNNING_CRON_LOCK:
|
||||
_RUNNING_CRON_JOBS[job_id] = time.time()
|
||||
|
|
@ -7977,97 +8177,7 @@ def handle_get(handler, parsed) -> bool:
|
|||
|
||||
# ── Gateway Status (GET) ──
|
||||
if parsed.path == "/api/gateway/status":
|
||||
import datetime
|
||||
identity_map = _load_gateway_session_identity_map()
|
||||
sessions_path = _gateway_session_metadata_path()
|
||||
|
||||
# Detect whether the gateway process is alive, independent of
|
||||
# connected messaging platforms. An empty identity_map just
|
||||
# means zero platforms connected, not that the gateway is down.
|
||||
#
|
||||
# agent_health.build_agent_health_payload() is the authoritative
|
||||
# signal: it reads gateway.status runtime metadata and returns a
|
||||
# tri-state `alive` field (True/False/None). This avoids the
|
||||
# false-negative where the gateway is running but has zero active
|
||||
# messaging sessions (empty identity_map).
|
||||
#
|
||||
# `alive` tri-state semantics:
|
||||
# True → gateway process is alive
|
||||
# False → gateway metadata exists but process is down
|
||||
# None → no gateway metadata/status available; this WebUI
|
||||
# setup is probably not configured with a gateway
|
||||
health = build_agent_health_payload()
|
||||
alive = health.get("alive")
|
||||
details = health.get("details") if isinstance(health.get("details"), dict) else {}
|
||||
health_reason = details.get("reason")
|
||||
health_state = details.get("state")
|
||||
health_gateway_state = details.get("gateway_state")
|
||||
if alive is True:
|
||||
running = True
|
||||
configured = True
|
||||
elif alive is False:
|
||||
running = False
|
||||
configured = True
|
||||
else: # alive is None
|
||||
# `alive is None` conflates two very different states:
|
||||
# (a) no gateway metadata at all → genuinely not configured
|
||||
# (b) gateway metadata EXISTS but is stale/inconclusive.
|
||||
# A stale-but-RUNNING gateway (freshly started, hasn't ticked
|
||||
# `updated_at` yet, or cross-container) still proves the gateway is
|
||||
# *configured* — the banner must not report "Gateway not configured"
|
||||
# just because no conversation has happened yet and identity_map is
|
||||
# empty (#3194).
|
||||
#
|
||||
# A stale-STOPPED gateway is deliberately NOT treated as configured:
|
||||
# agent_health emits `gateway_stale_stopped_state` precisely so a
|
||||
# stopped service the user isn't running reads like "no root gateway
|
||||
# configured" rather than nagging (#1944). So stale-stopped falls
|
||||
# through to the identity_map signal like the genuinely-unconfigured
|
||||
# case.
|
||||
gateway_running_metadata = (
|
||||
health_reason == "gateway_stale_running_state"
|
||||
or health_gateway_state == "running"
|
||||
)
|
||||
configured = True if gateway_running_metadata else bool(identity_map)
|
||||
running = bool(identity_map)
|
||||
|
||||
platforms_set: set[str] = set()
|
||||
for meta in identity_map.values():
|
||||
raw = meta.get("raw_source") or meta.get("platform") or ""
|
||||
norm = _normalize_messaging_source(raw)
|
||||
if norm:
|
||||
platforms_set.add(norm)
|
||||
_PLATFORM_LABELS = {
|
||||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"email": "Email",
|
||||
"web": "Web",
|
||||
"api": "API",
|
||||
}
|
||||
platforms = sorted(
|
||||
[{"name": p, "label": _PLATFORM_LABELS.get(p, p.title())} for p in platforms_set],
|
||||
key=lambda x: x["label"],
|
||||
)
|
||||
last_active = ""
|
||||
if running and sessions_path.exists():
|
||||
try:
|
||||
mtime = sessions_path.stat().st_mtime
|
||||
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, {
|
||||
"running": running,
|
||||
"configured": configured,
|
||||
"platforms": platforms,
|
||||
"last_active": last_active,
|
||||
"session_count": len(identity_map),
|
||||
"health": {
|
||||
"state": health_state,
|
||||
"reason": health_reason,
|
||||
"gateway_state": health_gateway_state,
|
||||
},
|
||||
})
|
||||
return j(handler, _gateway_status_payload())
|
||||
|
||||
# ── MCP Servers (GET) ──
|
||||
if parsed.path == "/api/mcp/servers":
|
||||
|
|
@ -9477,6 +9587,9 @@ def handle_post(handler, parsed) -> bool:
|
|||
if parsed.path == "/api/memory/write":
|
||||
return _handle_memory_write(handler, body)
|
||||
|
||||
if parsed.path in {"/api/gateway/start", "/api/gateway/stop", "/api/gateway/restart"}:
|
||||
return _handle_gateway_lifecycle(handler, parsed.path.rsplit("/", 1)[-1], body)
|
||||
|
||||
# ── Profile API (POST) ──
|
||||
if parsed.path == "/api/profile/switch":
|
||||
name = body.get("name", "").strip()
|
||||
|
|
|
|||
179
static/i18n.js
179
static/i18n.js
|
|
@ -186,6 +186,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
gateway_auth_label: 'Gateway authentication failed',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Model not found',
|
||||
model_custom_label: 'Custom model ID',
|
||||
model_custom_placeholder: 'e.g. openai/gpt-5.4',
|
||||
|
|
@ -1684,6 +1697,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" potrebbe non funzionare con il provider configurato (${p}). Invia comunque, o esegui \`hermes model\` nel terminale per cambiare.`,
|
||||
provider_mismatch_label: 'Provider non corrispondente',
|
||||
gateway_auth_label: 'Autenticazione Gateway non riuscita',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Modello non trovato',
|
||||
model_custom_label: 'ID modello personalizzato',
|
||||
model_custom_placeholder: 'es. openai/gpt-5.4',
|
||||
|
|
@ -3173,6 +3199,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" は設定されたプロバイダ (${p}) で動作しない可能性があります。このまま送信するか、ターミナルで \`hermes model\` を実行して切り替えてください。`,
|
||||
provider_mismatch_label: 'プロバイダ不一致',
|
||||
gateway_auth_label: 'ゲートウェイ認証に失敗しました',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'モデルが見つかりません',
|
||||
model_custom_label: 'カスタムモデルID',
|
||||
model_custom_placeholder: '例: openai/gpt-5.4',
|
||||
|
|
@ -4639,6 +4678,19 @@ const LOCALES = {
|
|||
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
|
||||
provider_mismatch_label: 'Несовпадение провайдера',
|
||||
gateway_auth_label: 'Сбой аутентификации Gateway',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Модель не найдена',
|
||||
model_custom_label: 'Пользовательский ID модели',
|
||||
model_custom_placeholder: 'например, openai/gpt-5.4',
|
||||
|
|
@ -6072,6 +6124,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
|
||||
provider_mismatch_label: 'Proveedor incompatible',
|
||||
gateway_auth_label: 'Error de autenticación de Gateway',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Modelo no encontrado',
|
||||
model_custom_label: 'ID de modelo personalizado',
|
||||
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
|
||||
|
|
@ -7497,6 +7562,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
|
||||
provider_mismatch_label: 'Provider-Konflikt',
|
||||
gateway_auth_label: 'Gateway-Authentifizierung fehlgeschlagen',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Modell nicht gefunden',
|
||||
// commands.js
|
||||
cmd_help: 'Verfügbare Befehle auflisten',
|
||||
|
|
@ -8926,6 +9004,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`,
|
||||
provider_mismatch_label: '提供商不匹配',
|
||||
gateway_auth_label: 'Gateway 身份验证失败',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: '未找到模型',
|
||||
model_custom_label: '自定义模型 ID',
|
||||
model_custom_placeholder: '例如 openai/gpt-5.4',
|
||||
|
|
@ -10375,6 +10466,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`「${m}」可能無法搭配已設定的供應商(${p})使用。仍要送出,或在終端機執行 \`hermes model\` 來切換。`,
|
||||
provider_mismatch_label: '供應商不相符',
|
||||
gateway_auth_label: 'Gateway 驗證失敗',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: '未找到模型',
|
||||
model_custom_label: '自訂模型 ID',
|
||||
model_custom_placeholder: '例如 openai/gpt-5.4',
|
||||
|
|
@ -11775,6 +11879,24 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" pode não funcionar com seu provedor configurado (${p}). Enviar assim mesmo, ou execute \`hermes model\` no terminal para trocar.`,
|
||||
provider_mismatch_label: 'Provedor incompatível',
|
||||
gateway_auth_label: 'Falha na autenticação do Gateway',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_not_configured: 'Gateway not configured',
|
||||
gateway_not_running: 'Gateway not running',
|
||||
gateway_running: 'Running',
|
||||
gateway_metadata_stale: 'Gateway metadata stale',
|
||||
gateway_endpoint_unreachable: 'Gateway endpoint not reachable',
|
||||
gateway_last_active: 'Last active',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Modelo não encontrado',
|
||||
composer_mobile_workspace: 'Workspace',
|
||||
composer_mobile_model: 'Modelo',
|
||||
|
|
@ -13145,6 +13267,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
gateway_auth_label: 'Gateway 인증 실패',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Model not found',
|
||||
model_custom_label: 'Custom model ID',
|
||||
model_custom_placeholder: 'e.g. openai/gpt-5.4',
|
||||
|
|
@ -14639,6 +14774,24 @@ const LOCALES = {
|
|||
model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle',
|
||||
provider_mismatch_label: 'Inadéquation des fournisseurs',
|
||||
gateway_auth_label: 'Échec de l’authentification Gateway',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_not_configured: 'Gateway not configured',
|
||||
gateway_not_running: 'Gateway not running',
|
||||
gateway_running: 'Running',
|
||||
gateway_metadata_stale: 'Gateway metadata stale',
|
||||
gateway_endpoint_unreachable: 'Gateway endpoint not reachable',
|
||||
gateway_last_active: 'Last active',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Modèle introuvable',
|
||||
model_custom_label: 'ID de modèle personnalisé',
|
||||
model_custom_placeholder: 'par ex. openai/gpt-5.4',
|
||||
|
|
@ -16062,6 +16215,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`,
|
||||
provider_mismatch_label: 'Sağlayıcı uyumsuzluğu',
|
||||
gateway_auth_label: 'Gateway kimlik doğrulaması başarısız',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
model_not_found_label: 'Model bulunamadı',
|
||||
model_custom_label: 'Özel model kimliği',
|
||||
model_custom_placeholder: 'örneğin openai/gpt-5.4',
|
||||
|
|
@ -17553,6 +17719,19 @@ const LOCALES = {
|
|||
provider_mismatch_warning: (m,p)=>`"${m}" może nie działać z Twoim skonfigurowanym dostawcą (${p}). Wyślij mimo to lub uruchom \`hermes model\` w terminalu, aby przełączyć.`,
|
||||
provider_mismatch_label: 'Niezgodność dostawcy',
|
||||
gateway_auth_label: 'Uwierzytelnianie bramy nie powiodło się',
|
||||
gateway_start: 'Uruchom',
|
||||
gateway_stop: 'Zatrzymaj',
|
||||
gateway_restart: 'Uruchom ponownie',
|
||||
gateway_start_success: 'Uruchamianie bramy zakończone.',
|
||||
gateway_stop_success: 'Zatrzymywanie bramy zakończone.',
|
||||
gateway_restart_success: 'Ponowne uruchamianie bramy zakończone.',
|
||||
gateway_start_failed: 'Uruchamianie bramy nie powiodło się',
|
||||
gateway_stop_failed: 'Zatrzymywanie bramy nie powiodło się',
|
||||
gateway_restart_failed: 'Ponowne uruchamianie bramy nie powiodło się',
|
||||
gateway_running: 'Działa',
|
||||
gateway_session: 'sesja',
|
||||
gateway_sessions: 'sesje',
|
||||
gateway_status_load_failed: 'Nie udało się załadować statusu bramy',
|
||||
model_not_found_label: 'Model nie został znaleziony',
|
||||
model_custom_label: 'Niestandardowy ID modelu',
|
||||
model_custom_placeholder: 'np. openai/gpt-5.4',
|
||||
|
|
|
|||
|
|
@ -9175,37 +9175,64 @@ function loadMcpTools(){
|
|||
filterMcpTools();
|
||||
}).catch(()=>{list.innerHTML=`<div class="mcp-tool-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_tools_load_failed'))}</div>`});
|
||||
}
|
||||
let _gatewayActionInFlight=false;
|
||||
function _gatewayActionButton(action){
|
||||
const labels={start:t('gateway_start'),stop:t('gateway_stop'),restart:t('gateway_restart')};
|
||||
return `<button class="sm-btn gateway-action-btn" data-gateway-action="${esc(action)}" onclick="_gatewayAction('${esc(action)}')" ${_gatewayActionInFlight?'disabled':''} style="padding:5px 10px;font-size:12px">${esc(labels[action]||action)}</button>`;
|
||||
}
|
||||
function _gatewayActionControls(r){
|
||||
const actions=(r&&r.running)?['stop','restart']:['start'];
|
||||
return `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">${actions.map(_gatewayActionButton).join('')}</div>`;
|
||||
}
|
||||
function _renderGatewayStatus(r){
|
||||
const card=$('gatewayStatusCard');
|
||||
if(!card||!r) return;
|
||||
if(!r.configured){
|
||||
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block"></span>${esc(t('gateway_not_configured'))}</div>${_gatewayActionControls(r)}`;
|
||||
return;
|
||||
}
|
||||
if(!r.running){
|
||||
const reason = _gatewayStatusReason(r);
|
||||
const statusLabel = reason === 'gateway_stale_running_state'
|
||||
? t('gateway_metadata_stale')
|
||||
: reason === 'remote_gateway_unreachable'
|
||||
? t('gateway_endpoint_unreachable')
|
||||
: t('gateway_not_running');
|
||||
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>${esc(statusLabel)}</div>${_gatewayActionControls(r)}`;
|
||||
return;
|
||||
}
|
||||
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};
|
||||
let badges='';
|
||||
if(r.platforms&&r.platforms.length){
|
||||
badges=r.platforms.map(p=>{
|
||||
const icon=platformIcons[p.name]||'📡';
|
||||
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
|
||||
}).join(' ');
|
||||
}
|
||||
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_last_active'))}: ${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
|
||||
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${r.session_count} ${esc(r.session_count!==1?t('gateway_sessions'):t('gateway_session'))}</span>`:'';
|
||||
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">${esc(t('gateway_running'))}</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>${_gatewayActionControls(r)}`;
|
||||
}
|
||||
function loadGatewayStatus(){
|
||||
const card=$('gatewayStatusCard');
|
||||
if(!card) return;
|
||||
api('/api/gateway/status').then(r=>{
|
||||
if(!r) return;
|
||||
if(!r.configured){
|
||||
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block"></span>${esc(t('gateway_not_configured'))}</div>`;
|
||||
return;
|
||||
}
|
||||
if(!r.running){
|
||||
const reason = _gatewayStatusReason(r);
|
||||
const statusLabel = reason === 'gateway_stale_running_state'
|
||||
? t('gateway_metadata_stale')
|
||||
: reason === 'remote_gateway_unreachable'
|
||||
? t('gateway_endpoint_unreachable')
|
||||
: t('gateway_not_running');
|
||||
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>${esc(statusLabel)}</div>`;
|
||||
return;
|
||||
}
|
||||
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};
|
||||
let badges='';
|
||||
if(r.platforms&&r.platforms.length){
|
||||
badges=r.platforms.map(p=>{
|
||||
const icon=platformIcons[p.name]||'📡';
|
||||
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
|
||||
}).join(' ');
|
||||
}
|
||||
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_last_active'))}${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
|
||||
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${esc(t('gateway_session_count',r.session_count))}</span>`:'';
|
||||
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">${esc(t('gateway_running_label'))}</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>`;
|
||||
}).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">${esc(t('gateway_load_failed'))}</div>`});
|
||||
return api('/api/gateway/status').then(r=>_renderGatewayStatus(r)).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">${esc(t('gateway_status_load_failed'))}</div>`});
|
||||
}
|
||||
async function _gatewayAction(action){
|
||||
if(_gatewayActionInFlight) return;
|
||||
_gatewayActionInFlight=true;
|
||||
const buttons=[...document.querySelectorAll('.gateway-action-btn')];
|
||||
buttons.forEach(btn=>{btn.disabled=true;});
|
||||
try{
|
||||
const result=await api(`/api/gateway/${encodeURIComponent(action)}`,{method:'POST',body:JSON.stringify({}),timeoutMs:70000,timeoutToast:false});
|
||||
if(typeof showToast==='function') showToast(result&&result.message?result.message:t(`gateway_${action}_success`),3000,'success');
|
||||
}catch(e){
|
||||
const msg=e&&e.message?e.message:String(e||'');
|
||||
if(typeof showToast==='function') showToast(`${t(`gateway_${action}_failed`)}${msg?': '+msg:''}`,5000,'error');
|
||||
}finally{
|
||||
_gatewayActionInFlight=false;
|
||||
await loadGatewayStatus();
|
||||
}
|
||||
}
|
||||
// Load MCP servers when system settings tab opens
|
||||
const _origSwitchSettings=switchSettingsSection;
|
||||
|
|
|
|||
265
tests/test_gateway_lifecycle_controls.py
Normal file
265
tests/test_gateway_lifecycle_controls.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.sent_headers: list[tuple[str, str]] = []
|
||||
self.body = bytearray()
|
||||
self.wfile = self
|
||||
self.headers = {}
|
||||
|
||||
def send_response(self, code):
|
||||
self.status = code
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.sent_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8"))
|
||||
|
||||
def get_json(self):
|
||||
return json.loads(self.body.decode("utf-8"))
|
||||
|
||||
|
||||
def _fake_agent(tmp_path):
|
||||
agent_dir = tmp_path / "hermes-agent"
|
||||
cli_dir = agent_dir / "hermes_cli"
|
||||
cli_dir.mkdir(parents=True)
|
||||
(cli_dir / "main.py").write_text("print('fake hermes cli')\n", encoding="utf-8")
|
||||
return agent_dir
|
||||
|
||||
|
||||
def _call_post(monkeypatch, path: str, body: dict | None = None):
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda handler: body or {})
|
||||
handler = _FakeHandler()
|
||||
routes.handle_post(handler, urlparse(path))
|
||||
return handler, handler.get_json()
|
||||
|
||||
|
||||
def test_gateway_start_runs_profile_scoped_agent_cli_and_returns_status(monkeypatch, tmp_path):
|
||||
from api import config, profiles, routes
|
||||
|
||||
agent_dir = _fake_agent(tmp_path)
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work")
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_gateway_status_payload",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"configured": True,
|
||||
"platforms": [],
|
||||
"last_active": "",
|
||||
"session_count": 0,
|
||||
"health": {},
|
||||
},
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append((cmd, kwargs))
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="gateway started\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(routes.subprocess, "run", fake_run)
|
||||
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/start", {"name": "telegram"})
|
||||
|
||||
assert handler.status == 200
|
||||
assert data["ok"] is True
|
||||
assert data["action"] == "start"
|
||||
assert "stdout" not in data
|
||||
assert "stderr" not in data
|
||||
assert data["status"]["running"] is True
|
||||
cmd, kwargs = calls[0]
|
||||
assert cmd[:2] == [sys.executable, str(agent_dir / "hermes_cli" / "main.py")]
|
||||
assert cmd[2:] == ["--profile", "work", "gateway", "start"]
|
||||
assert kwargs["cwd"] == str(agent_dir)
|
||||
assert kwargs["env"]["PYTHONUTF8"] == "1"
|
||||
assert kwargs["env"]["BROWSER"] == "echo"
|
||||
assert kwargs["capture_output"] is True
|
||||
assert kwargs["text"] is True
|
||||
|
||||
|
||||
def test_gateway_action_contention_returns_409_without_spawning(monkeypatch, tmp_path):
|
||||
"""A second lifecycle action while one holds the lock returns 409 and does
|
||||
NOT spawn an overlapping `hermes gateway` subprocess (server-side
|
||||
single-flight guard)."""
|
||||
from api import config, routes
|
||||
|
||||
agent_dir = _fake_agent(tmp_path)
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
spawned = []
|
||||
monkeypatch.setattr(
|
||||
routes.subprocess,
|
||||
"run",
|
||||
lambda cmd, **kw: spawned.append(cmd) or subprocess.CompletedProcess(cmd, 0, stdout="", stderr=""),
|
||||
)
|
||||
|
||||
# Simulate an in-flight action by holding the lock, then fire a second one.
|
||||
acquired = routes._GATEWAY_ACTION_LOCK.acquire(blocking=False)
|
||||
assert acquired, "lock should be free at test start"
|
||||
try:
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/restart", {})
|
||||
finally:
|
||||
routes._GATEWAY_ACTION_LOCK.release()
|
||||
|
||||
assert handler.status == 409, f"expected 409 on contention, got {handler.status}"
|
||||
assert data["ok"] is False
|
||||
assert spawned == [], "no subprocess should be spawned while another action holds the lock"
|
||||
|
||||
# After the lock is released, a normal action proceeds (lock is reusable).
|
||||
handler2, data2 = _call_post(monkeypatch, "/api/gateway/restart", {})
|
||||
assert handler2.status == 200
|
||||
assert len(spawned) == 1
|
||||
|
||||
|
||||
def test_gateway_stop_failure_returns_bounded_error(monkeypatch, tmp_path):
|
||||
from api import config, profiles, routes
|
||||
agent_dir = _fake_agent(tmp_path)
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 7, stdout="partial output\n", stderr="stop failed\n")
|
||||
|
||||
monkeypatch.setattr(routes.subprocess, "run", fake_run)
|
||||
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/stop")
|
||||
|
||||
assert handler.status == 500
|
||||
assert data["ok"] is False
|
||||
assert data["action"] == "stop"
|
||||
assert data["returncode"] == 7
|
||||
assert "stdout" not in data
|
||||
assert "stderr" not in data
|
||||
assert data["error"] == "Gateway stop failed with exit code 7"
|
||||
|
||||
|
||||
def test_gateway_restart_uses_restart_subcommand(monkeypatch, tmp_path):
|
||||
from api import config, profiles, routes
|
||||
|
||||
agent_dir = _fake_agent(tmp_path)
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_gateway_status_payload",
|
||||
lambda: {
|
||||
"running": True,
|
||||
"configured": True,
|
||||
"platforms": [],
|
||||
"last_active": "",
|
||||
"session_count": 0,
|
||||
"health": {},
|
||||
},
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="gateway restarted\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(routes.subprocess, "run", fake_run)
|
||||
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/restart")
|
||||
|
||||
assert handler.status == 200
|
||||
assert data["ok"] is True
|
||||
assert data["action"] == "restart"
|
||||
assert calls[0][-2:] == ["gateway", "restart"]
|
||||
|
||||
|
||||
def test_gateway_lifecycle_timeout_returns_gateway_timeout(monkeypatch, tmp_path):
|
||||
from api import config, profiles, routes
|
||||
|
||||
agent_dir = _fake_agent(tmp_path)
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=cmd, timeout=60, output="partial", stderr="slow")
|
||||
|
||||
monkeypatch.setattr(routes.subprocess, "run", fake_run)
|
||||
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/restart")
|
||||
|
||||
assert handler.status == 504
|
||||
assert data["ok"] is False
|
||||
assert data["action"] == "restart"
|
||||
assert data["error"] == "Gateway restart timed out after 60 seconds"
|
||||
assert "stdout" not in data
|
||||
assert "stderr" not in data
|
||||
|
||||
|
||||
def test_gateway_lifecycle_invalid_action_returns_bad_request(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "_run_gateway_lifecycle_command", lambda action: (_ for _ in ()).throw(ValueError("unsupported gateway action")))
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_gateway_lifecycle(handler, "bogus", {})
|
||||
data = handler.get_json()
|
||||
|
||||
assert handler.status == 400
|
||||
assert data["error"] == "unsupported gateway action"
|
||||
|
||||
|
||||
def test_gateway_lifecycle_missing_cli_returns_sanitized_error(monkeypatch, tmp_path):
|
||||
from api import config, profiles
|
||||
|
||||
agent_dir = tmp_path / "missing-agent"
|
||||
agent_dir.mkdir()
|
||||
monkeypatch.setattr(config, "_AGENT_DIR", agent_dir)
|
||||
monkeypatch.setattr(config, "PYTHON_EXE", sys.executable)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
|
||||
handler, data = _call_post(monkeypatch, "/api/gateway/start")
|
||||
|
||||
assert handler.status == 500
|
||||
assert data["ok"] is False
|
||||
assert data["action"] == "start"
|
||||
assert data["error"] == "Hermes agent CLI entrypoint not found"
|
||||
|
||||
|
||||
def test_gateway_lifecycle_frontend_renders_valid_actions():
|
||||
from pathlib import Path
|
||||
|
||||
panels = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
assert "api(`/api/gateway/${encodeURIComponent(action)}`" in panels
|
||||
assert "timeoutMs:70000" in panels
|
||||
assert "timeoutToast:false" in panels
|
||||
assert "(r&&r.running)?['stop','restart']:['start']" in panels
|
||||
assert "document.querySelectorAll('.gateway-action-btn')" in panels
|
||||
assert "await loadGatewayStatus();" in panels
|
||||
|
||||
|
||||
def test_gateway_lifecycle_i18n_keys_exist():
|
||||
from pathlib import Path
|
||||
|
||||
i18n = (Path(__file__).resolve().parents[1] / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
for key in (
|
||||
"gateway_start",
|
||||
"gateway_stop",
|
||||
"gateway_restart",
|
||||
"gateway_start_failed",
|
||||
"gateway_stop_failed",
|
||||
"gateway_restart_failed",
|
||||
):
|
||||
assert f"{key}:" in i18n
|
||||
Loading…
Reference in a new issue