diff --git a/CHANGELOG.md b/CHANGELOG.md index c72f76c1..cc639f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/routes.py b/api/routes.py index 37b1d0e7..e94a2c76 100644 --- a/api/routes.py +++ b/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() diff --git a/static/i18n.js b/static/i18n.js index 4b1301d6..47b4bf0f 100644 --- a/static/i18n.js +++ b/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', diff --git a/static/panels.js b/static/panels.js index b62030d7..3db726f3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9175,37 +9175,64 @@ function loadMcpTools(){ filterMcpTools(); }).catch(()=>{list.innerHTML=`
${esc(t('mcp_tools_load_failed'))}
`}); } +let _gatewayActionInFlight=false; +function _gatewayActionButton(action){ + const labels={start:t('gateway_start'),stop:t('gateway_stop'),restart:t('gateway_restart')}; + return ``; +} +function _gatewayActionControls(r){ + const actions=(r&&r.running)?['stop','restart']:['start']; + return `
${actions.map(_gatewayActionButton).join('')}
`; +} +function _renderGatewayStatus(r){ + const card=$('gatewayStatusCard'); + if(!card||!r) return; + if(!r.configured){ + card.innerHTML=`
${esc(t('gateway_not_configured'))}
${_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=`
${esc(statusLabel)}
${_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 `${icon} ${esc(p.label)}`; + }).join(' '); + } + const lastActive=r.last_active?`${esc(t('gateway_last_active'))}: ${esc(new Date(r.last_active).toLocaleString())}`:''; + const sessionInfo=r.session_count?`${r.session_count} ${esc(r.session_count!==1?t('gateway_sessions'):t('gateway_session'))}`:''; + card.innerHTML=`
${esc(t('gateway_running'))}
${badges?`
${badges}
`:''}
${sessionInfo}${lastActive}
${_gatewayActionControls(r)}`; +} function loadGatewayStatus(){ const card=$('gatewayStatusCard'); if(!card) return; - api('/api/gateway/status').then(r=>{ - if(!r) return; - if(!r.configured){ - card.innerHTML=`
${esc(t('gateway_not_configured'))}
`; - 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=`
${esc(statusLabel)}
`; - 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 `${icon} ${esc(p.label)}`; - }).join(' '); - } - const lastActive=r.last_active?`${esc(t('gateway_last_active'))}${esc(new Date(r.last_active).toLocaleString())}`:''; - const sessionInfo=r.session_count?`${esc(t('gateway_session_count',r.session_count))}`:''; - card.innerHTML=`
${esc(t('gateway_running_label'))}
${badges?`
${badges}
`:''}
${sessionInfo}${lastActive}
`; - }).catch(()=>{card.innerHTML=`
${esc(t('gateway_load_failed'))}
`}); + return api('/api/gateway/status').then(r=>_renderGatewayStatus(r)).catch(()=>{card.innerHTML=`
${esc(t('gateway_status_load_failed'))}
`}); +} +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; diff --git a/tests/test_gateway_lifecycle_controls.py b/tests/test_gateway_lifecycle_controls.py new file mode 100644 index 00000000..0d655308 --- /dev/null +++ b/tests/test_gateway_lifecycle_controls.py @@ -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