diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 14ff798274..f2d9830172 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -30,7 +30,9 @@ import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { notify } from '@/store/notifications' import { $sessions, sessionPinId } from '@/store/session' +import { confirmSharedGatewayRestart } from '@/store/system-actions' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -267,6 +269,13 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on async (kind: 'restart' | 'update') => { setSystemError('') + // A profile served by the shared multiplexer restarts every bot on this device: ask first. + const shared = kind === 'restart' ? await confirmSharedGatewayRestart() : null + + if (shared === false) { + return + } + try { const started = kind === 'restart' ? await restartGateway() : await updateHermes() let nextStatus: ActionStatusResponse | null = null @@ -283,6 +292,10 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } } + if (shared && nextStatus && !nextStatus.running && (nextStatus.exit_code ?? 0) === 0) { + notify({ kind: 'success', message: cc.sharedGatewayRestarted(shared.length) }) + } + if (!nextStatus) { const pendingStatus = { exit_code: null, diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 42e9ee204c..d653a26977 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -1024,6 +1024,19 @@ function SectionTitle({ children }: { children: React.ReactNode }) { function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) { const { t } = useI18n() + // A served secondary's api_server/webhook live on the shared gateway listener under + // /p//: the state pill says connected, this line says where to point the client. + if (platform.ingress_url) { + return ( +

+ {t.messaging.sharedListenerUrl}{' '} + + {platform.ingress_url} + +

+ ) + } + if (!platform.enabled || platform.state === 'connected') { return null } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index d9381a50f3..6d70110018 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1319,6 +1319,10 @@ export const ar = defineLocale({ restartGateway: 'إعادة تشغيل البوابة', openBrowser: 'فتح المتصفح', gatewayRestartFailed: 'فشل إعادة تشغيل البوابة.', + sharedGatewayRestartTitle: 'إعادة تشغيل البوابة المشتركة؟', + sharedGatewayRestartDescription: bots => `تتم إعادة اتصال جميع البوتات على هذا الجهاز: ${bots}`, + sharedGatewayRestartConfirm: 'إعادة تشغيل الكل', + sharedGatewayRestarted: count => `تمت إعادة تشغيل البوابة المشتركة (${count} بوت)`, updateHermes: 'تحديث Hermes', reloadWindow: 'إعادة تحميل النافذة', actionRunning: 'الإجراء قيد التشغيل', @@ -1364,6 +1368,7 @@ export const ar = defineLocale({ }, unknown: 'غير معروف', hintPendingRestart: 'تحتاج إعادة تشغيل لتطبيق التغييرات.', + sharedListenerUrl: 'يُخدم عبر مستمع البوابة المشتركة على', hintGatewayStopped: 'البوابة متوقفة.', restartNeeded: 'تم الحفظ. أعد تشغيل بوابة المراسلة لتطبيق الإعدادات الجديدة.', restartNow: 'إعادة التشغيل الآن', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d22f854b67..acbde2d114 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1918,6 +1918,10 @@ export const en: Translations = { restartGateway: 'Restart gateway', openBrowser: 'Open browser', gatewayRestartFailed: 'Gateway restart failed.', + sharedGatewayRestartTitle: 'Restart the shared gateway?', + sharedGatewayRestartDescription: bots => `All bots on this device reconnect: ${bots}`, + sharedGatewayRestartConfirm: 'Restart all', + sharedGatewayRestarted: count => `Shared gateway restarted (${count} ${count === 1 ? 'bot' : 'bots'})`, updateHermes: 'Update Hermes', reloadWindow: 'Reload window', actionRunning: 'running', @@ -2011,6 +2015,7 @@ export const en: Translations = { }, unknown: 'Unknown', hintPendingRestart: 'Restart the gateway from the status bar to apply this change.', + sharedListenerUrl: 'Served on the shared gateway listener at', hintGatewayStopped: 'Start the gateway from the status bar to connect.', credentialsSet: 'Credentials set', needsSetup: 'Needs setup', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 1ff0632682..cdd512551c 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1637,6 +1637,10 @@ export const ja = defineLocale({ restartGateway: 'ゲートウェイを再起動', openBrowser: 'ブラウザを開く', gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。', + sharedGatewayRestartTitle: '共有ゲートウェイを再起動しますか?', + sharedGatewayRestartDescription: bots => `このデバイス上のすべてのボットが再接続します: ${bots}`, + sharedGatewayRestartConfirm: 'すべて再起動', + sharedGatewayRestarted: count => `共有ゲートウェイを再起動しました(${count} ボット)`, updateHermes: 'Hermes を更新', reloadWindow: 'ウィンドウを再読み込み', actionRunning: '実行中', @@ -1683,6 +1687,7 @@ export const ja = defineLocale({ }, unknown: '不明', hintPendingRestart: 'この変更を適用するにはステータスバーからゲートウェイを再起動してください。', + sharedListenerUrl: '共有ゲートウェイのリスナーで提供中:', hintGatewayStopped: 'ステータスバーからゲートウェイを起動して接続してください。', restartNeeded: '保存しました。新しい設定を反映するにはメッセージングゲートウェイを再起動してください。', restartNow: '今すぐ再起動', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 1f95a056d8..e8ffa262a3 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -1808,6 +1808,10 @@ export const ru = defineLocale({ restartGateway: 'Перезапустить шлюз', openBrowser: 'Открыть браузер', gatewayRestartFailed: 'Не удалось перезапустить шлюз.', + sharedGatewayRestartTitle: 'Перезапустить общий шлюз?', + sharedGatewayRestartDescription: bots => `Все боты на этом устройстве переподключатся: ${bots}`, + sharedGatewayRestartConfirm: 'Перезапустить все', + sharedGatewayRestarted: count => `Общий шлюз перезапущен (ботов: ${count})`, updateHermes: 'Обновить Hermes', reloadWindow: 'Перезагрузить окно', actionRunning: 'выполняется', @@ -1900,6 +1904,7 @@ export const ru = defineLocale({ }, unknown: 'Неизвестно', hintPendingRestart: 'Перезапустите шлюз из строки состояния, чтобы применить это изменение.', + sharedListenerUrl: 'Обслуживается общим слушателем шлюза по адресу', hintGatewayStopped: 'Запустите шлюз из строки состояния для подключения.', credentialsSet: 'Учётные данные заданы', needsSetup: 'Нужна настройка', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 26db819d41..0c16cb4b06 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1694,6 +1694,10 @@ export interface Translations { restartGateway: string openBrowser: string gatewayRestartFailed: string + sharedGatewayRestartTitle: string + sharedGatewayRestartDescription: (bots: string) => string + sharedGatewayRestartConfirm: string + sharedGatewayRestarted: (count: number) => string updateHermes: string reloadWindow: string actionRunning: string @@ -1777,6 +1781,7 @@ export interface Translations { states: Record unknown: string hintPendingRestart: string + sharedListenerUrl: string hintGatewayStopped: string credentialsSet: string needsSetup: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index b1f2c8448a..4759253d15 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1579,6 +1579,10 @@ export const zhHant = defineLocale({ restartGateway: '重新啟動閘道', openBrowser: '開啟瀏覽器', gatewayRestartFailed: '閘道重新啟動失敗。', + sharedGatewayRestartTitle: '重新啟動共享閘道?', + sharedGatewayRestartDescription: bots => `此裝置上的所有機器人都會重新連線:${bots}`, + sharedGatewayRestartConfirm: '全部重新啟動', + sharedGatewayRestarted: count => `共享閘道已重新啟動(${count} 個機器人)`, updateHermes: '更新 Hermes', reloadWindow: '重新載入視窗', actionRunning: '執行中', @@ -1625,6 +1629,7 @@ export const zhHant = defineLocale({ }, unknown: '未知', hintPendingRestart: '在狀態列重新啟動閘道以套用此變更。', + sharedListenerUrl: '透過共享閘道監聽器提供,位址為', hintGatewayStopped: '在狀態列啟動閘道以建立連線。', restartNeeded: '已儲存。請重新啟動訊息閘道以套用新設定。', restartNow: '立即重新啟動', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 9a6ff25d94..7e78217d1b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2084,6 +2084,10 @@ export const zh = defineLocale({ restartGateway: '重启网关', openBrowser: '打开浏览器', gatewayRestartFailed: '网关重启失败。', + sharedGatewayRestartTitle: '重启共享网关?', + sharedGatewayRestartDescription: bots => `此设备上的所有机器人都会重新连接:${bots}`, + sharedGatewayRestartConfirm: '全部重启', + sharedGatewayRestarted: count => `共享网关已重启(${count} 个机器人)`, updateHermes: '更新 Hermes', reloadWindow: '重新载入窗口', actionRunning: '运行中', @@ -2177,6 +2181,7 @@ export const zh = defineLocale({ }, unknown: '未知', hintPendingRestart: '在状态栏重启网关以应用此更改。', + sharedListenerUrl: '通过共享网关监听器提供,地址为', hintGatewayStopped: '在状态栏启动网关以建立连接。', credentialsSet: '凭据已设置', needsSetup: '需要设置', diff --git a/apps/desktop/src/lib/shared-gateway-restart.test.ts b/apps/desktop/src/lib/shared-gateway-restart.test.ts new file mode 100644 index 0000000000..87845161b5 --- /dev/null +++ b/apps/desktop/src/lib/shared-gateway-restart.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { sharedGatewayProfiles } from './shared-gateway-restart' + +describe('sharedGatewayProfiles', () => { + it('lists every bot the shared multiplexer carries, default first, when the profile is served', () => { + expect(sharedGatewayProfiles({ gateway_shared_with: ['beta', 'alpha', 'default'] })).toEqual([ + 'default', + 'alpha', + 'beta' + ]) + }) + + it('keeps the plain restart for standalone gateways and older backends', () => { + // Standalone profile: the backend answers null. + expect(sharedGatewayProfiles({ gateway_shared_with: null })).toBeNull() + // Older backend: the key does not exist at all. + expect(sharedGatewayProfiles({})).toBeNull() + expect(sharedGatewayProfiles(null)).toBeNull() + // A multiplexer carrying nobody else restarts only itself: no "all bots" copy. + expect(sharedGatewayProfiles({ gateway_shared_with: ['default'] })).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/shared-gateway-restart.ts b/apps/desktop/src/lib/shared-gateway-restart.ts new file mode 100644 index 0000000000..17a49c336f --- /dev/null +++ b/apps/desktop/src/lib/shared-gateway-restart.ts @@ -0,0 +1,21 @@ +import type { StatusResponse } from '@/types/hermes' + +/** Profiles a gateway restart would blip when the polled profile is carried by the shared + * multiplexer, in display order (default first, then as recorded). `null` for a standalone + * gateway or an older backend that does not report `gateway_shared_with` — those keep the + * plain no-dialog restart. A record naming only one profile is not shared either. */ +export function sharedGatewayProfiles(status: Pick | null | undefined): null | string[] { + const shared = status?.gateway_shared_with + + if (!Array.isArray(shared)) { + return null + } + + const names = [...new Set(shared.map(name => String(name).trim()).filter(Boolean))] + + if (names.length < 2) { + return null + } + + return names.sort((left, right) => (left === 'default' ? -1 : right === 'default' ? 1 : left.localeCompare(right))) +} diff --git a/apps/desktop/src/store/system-actions.ts b/apps/desktop/src/store/system-actions.ts index 1867722785..1b2c67ea5d 100644 --- a/apps/desktop/src/store/system-actions.ts +++ b/apps/desktop/src/store/system-actions.ts @@ -1,8 +1,10 @@ import { atom } from 'nanostores' -import { getActionStatus, restartGateway } from '@/hermes' +import { getActionStatus, getStatus, restartGateway } from '@/hermes' import { translateNow } from '@/i18n' -import { notifyError } from '@/store/notifications' +import { sharedGatewayProfiles } from '@/lib/shared-gateway-restart' +import { confirm } from '@/store/confirm' +import { notify, notifyError } from '@/store/notifications' import type { ActionResponse } from '@/types/hermes' const POLL_ATTEMPTS = 18 @@ -34,6 +36,37 @@ async function awaitAction(name: string): Promise { } } +// Under `gateway.multiplex_profiles` the profile in view has no gateway of its +// own: "Restart gateway" restarts the ONE shared multiplexer and every bot on +// this device blips. Ask first, naming them; standalone gateways (and older +// backends that do not report `gateway_shared_with`) keep the silent restart. +// Resolves the served list when the user confirmed, `null` when nothing is +// shared, `false` when they cancelled. +export async function confirmSharedGatewayRestart(): Promise { + let shared: null | string[] = null + + try { + shared = sharedGatewayProfiles(await getStatus()) + } catch { + // Status unavailable: fall back to the plain restart rather than blocking it. + return null + } + + if (!shared) { + return null + } + + const ok = await confirm({ + title: translateNow('commandCenter.sharedGatewayRestartTitle'), + description: translateNow('commandCenter.sharedGatewayRestartDescription', shared.join(', ')), + confirmLabel: translateNow('commandCenter.sharedGatewayRestartConfirm'), + cancelLabel: translateNow('common.cancel'), + destructive: true + }) + + return ok ? shared : false +} + // Restart the messaging gateway, surfacing progress in the statusbar gateway // indicator. Self-contained and never rejects, so every trigger — Cmd+K, the // messaging save/toggle toasts — gets identical feedback from a plain @@ -41,12 +74,22 @@ async function awaitAction(name: string): Promise { // Resolves `true` when the restart child completed cleanly (callers that keep // a "restart needed" banner clear it on that signal only). export async function runGatewayRestart(): Promise { + const shared = await confirmSharedGatewayRestart() + + if (shared === false) { + return false + } + $gatewayRestarting.set(true) try { const started: ActionResponse = await restartGateway() await awaitAction(started.name) + if (shared) { + notify({ kind: 'success', message: translateNow('commandCenter.sharedGatewayRestarted', shared.length) }) + } + return true } catch (err) { notifyError(err, translateNow('commandCenter.gatewayRestartFailed')) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 2c164a64a9..8773659e64 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -280,6 +280,9 @@ export interface MessagingPlatformInfo { gateway_running: boolean home_channel?: MessagingHomeChannel | null id: string + /** Served secondary under a multiplexed gateway: the /p// URL on the shared listener + * the client (or vendor console) must call. Null for standalone and default-profile platforms. */ + ingress_url?: null | string name: string state?: null | string updated_at?: null | string @@ -1332,6 +1335,9 @@ export interface StatusResponse { gateway_pid: number | null gateway_platforms: Record gateway_running: boolean + /** Every profile the gateway process serves when the polled profile is carried by the shared + * multiplexer (e.g. ['default', 'alpha', 'beta']); null/absent for a standalone gateway. */ + gateway_shared_with?: string[] | null gateway_state: string | null gateway_updated_at: string | null hermes_home: string diff --git a/gateway/config.py b/gateway/config.py index b299f2c36c..82de453ef9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -249,6 +249,8 @@ PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {"feishu": "webhook"} # Port-binders whose /p// surface is a MIRROR served by the default's own adapter; a secondary # never gets an instance of these (api_server: /p//v1/..., webhook: profile-bound routes). SHARED_LISTENER_MIRROR_PLATFORMS = frozenset({"api_server", "webhook"}) +# Path a client appends to ``/p/`` to reach each mirror. +SHARED_LISTENER_MIRROR_PATHS: dict[str, str] = {"api_server": "/v1", "webhook": "/webhooks/"} def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool: diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5ac4d9a57a..5c4a2bf4ae 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -3976,7 +3976,8 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter): "config.yaml: platforms.api_server.port", self.name, self._host, self._port, exc) return False - self._mark_connected() + from gateway.platforms.shared_ingress import listener_base_url + self._mark_connected(listener_base=listener_base_url(self._host, self._port)) logger.info( "[%s] API server listening on http://%s:%d (model: %s)", self.name, self._host, self._port, self._model_name) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1a06ceae1b..7d957ce897 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2042,14 +2042,19 @@ class BasePlatformAdapter(ABC): """ return False - def _mark_connected(self) -> None: + def _mark_connected(self, *, listener_base: Optional[str] = None) -> None: + """``listener_base`` (``http://host:port``) is stamped by port-binders after a REAL bind: under the + multiplexer it is the shared listener a served profile's ``/p//`` mirror hangs off, and + what the dashboard/Desktop report as that profile's api_server/webhook URL.""" self._running = True self._fatal_error_code = self._fatal_error_message = None self._fatal_error_retryable = True if self.send_path_degraded: self._mark_degraded() else: - self._write_runtime_status_safe("connected", platform_state="connected", error_code=None, error_message=None) + extra = {"listener_base": listener_base} if listener_base else {} + self._write_runtime_status_safe( + "connected", platform_state="connected", error_code=None, error_message=None, **extra) def _mark_degraded(self) -> None: """Publish ``retrying`` for a running adapter whose delivery path is unproven.""" diff --git a/gateway/platforms/shared_ingress.py b/gateway/platforms/shared_ingress.py index 89b70925b5..ae3db657e6 100644 --- a/gateway/platforms/shared_ingress.py +++ b/gateway/platforms/shared_ingress.py @@ -29,6 +29,14 @@ def shared_ingress_profile(adapter: Any) -> Optional[str]: return getattr(adapter, "_shared_listener_profile", None) or None +def listener_base_url(host: Any, port: Any) -> str: + """``http://host:port`` clients use to reach a listener bound on ``host`` (wildcards → loopback).""" + host = "127.0.0.1" if host is None or str(host).strip() in _WILDCARD_HOSTS else str(host) + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return f"http://{host}:{port or 0}" + + def shared_listener_base(runner: Any) -> Optional[str]: """``http://host:port`` of the default profile's live listener (api_server first, then webhook).""" from gateway.config import Platform @@ -37,11 +45,7 @@ def shared_listener_base(runner: Any) -> Optional[str]: adapter = adapters.get(platform) if adapter is None: continue - host = getattr(adapter, "_host", None) - host = "127.0.0.1" if host is None or str(host).strip() in _WILDCARD_HOSTS else str(host) - if ":" in host and not host.startswith("["): - host = f"[{host}]" - return f"http://{host}:{getattr(adapter, '_port', 0)}" + return listener_base_url(getattr(adapter, "_host", None), getattr(adapter, "_port", 0)) return None diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index d70cde0e6b..c8005a0df3 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -235,7 +235,8 @@ class WebhookAdapter(BasePlatformAdapter): logger.error("[webhook] Could not bind %s:%d: %s. Set a different host or port in config.yaml under " "platforms.webhook.extra.", self._host or "all IPv4+IPv6 interfaces", self._port, exc) return False - self._mark_connected() + from gateway.platforms.shared_ingress import listener_base_url + self._mark_connected(listener_base=listener_base_url(self._host, self._port)) logger.info("[webhook] Listening on %s:%d — routes: %s", self._host or "* (all interfaces, IPv4+IPv6)", self._port, ", ".join(self._routes.keys()) or "(none configured)") self._wire_plugin_handlers(None) diff --git a/gateway/status.py b/gateway/status.py index e82cfeb66f..2176a37f01 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -804,7 +804,7 @@ def write_runtime_status( active_agents: Any = _UNSET, platform: Any = _UNSET, platform_state: Any = _UNSET, error_code: Any = _UNSET, error_message: Any = _UNSET, needs_attention: Any = _UNSET, retrying_since: Any = _UNSET, served_profiles: Any = _UNSET, session_store: Any = _UNSET, - ingress_url: Any = _UNSET, clear_profile_platforms: bool = False, + ingress_url: Any = _UNSET, listener_base: Any = _UNSET, clear_profile_platforms: bool = False, drop_profile_platforms: Optional[str] = None, ) -> None: """Persist gateway runtime health information for diagnostics/status. ``drop_profile_platforms`` @@ -848,6 +848,9 @@ def write_runtime_status( ("retrying_since", retrying_since, None), # Shared-listener secondaries: the /p// callback URL the vendor console must target. ("ingress_url", ingress_url, None), + # Bound listener (``http://host:port``) of the default's api_server/webhook: a served + # profile's mirror of that platform is reported off it (``/p//...``). + ("listener_base", listener_base, None), )) # Per-entry writer provenance: top-level pid/start_time only identify the most recent # writer; /api/status tells "live" from "preserved" by exact (pid, start_time) equality. @@ -945,15 +948,43 @@ def multiplexer_liveness_for_profile(profile_dir: Path) -> Optional[tuple[int, d return pid, read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {} +def shared_listener_mirror_platforms(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]: + """Entries for the api_server/webhook mirrors a served ``profile`` gets from the DEFAULT's + listener. The multiplexer never builds those adapters for a secondary (``gateway.run_adapters`` + skips them: ``SHARED_LISTENER_MIRROR_PLATFORMS``), so the record has no ``:api_server`` + entry and every reader fell through to ``pending_restart`` — "Restart needed" forever while + ``/p//v1/...`` answered. Only a live default entry is mirrored; its state is the profile's + state, plus the ``/p/`` URL the client must actually call. + """ + from gateway.config import SHARED_LISTENER_MIRROR_PATHS, SHARED_LISTENER_MIRROR_PLATFORMS + plats = (runtime or {}).get("platforms") + if not profile or profile == "default" or not isinstance(plats, dict): + return {} + mirrored: dict[str, Any] = {} + for name in sorted(SHARED_LISTENER_MIRROR_PLATFORMS): + entry = plats.get(name) + if not isinstance(entry, dict) or entry.get("state") not in {"connected", "connecting", "retrying"}: + continue + # api_server and webhook bind separate ports; each mirror hangs off its own listener. A record + # from an older gateway carries no ``listener_base``: connected, URL unknown. + base = entry.get("listener_base") + url = f"{base}/p/{profile}{SHARED_LISTENER_MIRROR_PATHS.get(name, '')}" if isinstance(base, str) and base else None + mirrored[name] = {k: v for k, v in entry.items() if k != "listener_base"} + mirrored[name].update(ingress_url=url, mirrored_from="default") + return mirrored + + def profile_platforms_from_multiplexer(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]: """The ``:`` entries of a multiplexer record, re-keyed to bare platform names — the - same shape a standalone gateway for ``profile`` writes into its own ``gateway_state.json``.""" + same shape a standalone gateway for ``profile`` writes into its own ``gateway_state.json`` — plus the + default listener's api_server/webhook mirrors the profile is served through (``ingress_url`` set).""" plats = (runtime or {}).get("platforms") if not isinstance(plats, dict): return {} prefix = f"{profile}:" - return {key[len(prefix):]: value for key, value in plats.items() - if isinstance(key, str) and key.startswith(prefix) and isinstance(value, dict)} + own = {key[len(prefix):]: value for key, value in plats.items() + if isinstance(key, str) and key.startswith(prefix) and isinstance(value, dict)} + return {**shared_listener_mirror_platforms(runtime, profile), **own} def resolve_gateway_liveness( diff --git a/hermes_cli/gateway_multiplex_served.py b/hermes_cli/gateway_multiplex_served.py index ae45c56a00..c2eadf6ff3 100644 --- a/hermes_cli/gateway_multiplex_served.py +++ b/hermes_cli/gateway_multiplex_served.py @@ -48,7 +48,7 @@ def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict serves on its shared listener (``:`` entries carrying ``ingress_url``). This is what the user pastes into the vendor console (Twilio, LINE, Teams, ...). ``profile`` narrows the map.""" from hermes_constants import get_default_hermes_root - from gateway.status import read_runtime_status + from gateway.status import read_runtime_status, shared_listener_mirror_platforms if live_default_gateway_pid() is None: return {} runtime = read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {} @@ -66,6 +66,12 @@ def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict if profile and name != profile: continue urls.setdefault(name, {})[platform] = str(url) + # api_server/webhook are the default's adapters mirrored at /p// (no entry of their own). + served = [str(p) for p in (runtime.get("served_profiles") or []) if p and p != "default"] + for name in served if not profile else [p for p in served if p == profile]: + for platform, entry in shared_listener_mirror_platforms(runtime, name).items(): + if entry.get("ingress_url"): + urls.setdefault(name, {})[platform] = str(entry["ingress_url"]) return urls diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py index a5d7278a63..d4d8b0b652 100644 --- a/hermes_cli/web_routers/status.py +++ b/hermes_cli/web_routers/status.py @@ -285,10 +285,14 @@ async def _resolve_gateway_status(profile_dir: Optional[Path], health_url) -> Di if gateway_running and gateway_state is None and remote_health_body is not None: gateway_state = "running" + # ``liveness.runtime`` is set only when the shared multiplexer answered for a served profile: its + # gateway IS that process, so name every bot a restart would blip ("default, alpha, beta"). + served = (liveness.runtime or {}).get("served_profiles") return { "runtime": runtime, "gateway_running": gateway_running, "gateway_pid": liveness.pid, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, - "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at} + "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, + "gateway_shared_with": [str(p) for p in served] if isinstance(served, list) else None} def _auth_gate_status() -> Dict[str, Any]: @@ -434,6 +438,8 @@ async def get_status(profile: Optional[str] = None): "gateway_platforms": gateway["gateway_platforms"], "gateway_exit_reason": gateway["gateway_exit_reason"], "gateway_updated_at": gateway["gateway_updated_at"], + # Non-null only for a profile served by the shared multiplexer: every profile that process carries. + "gateway_shared_with": gateway["gateway_shared_with"], "active_agents": active_agents, "gateway_busy": derive_gateway_busy( gateway_running=gateway_running, gateway_state=gateway_state, diff --git a/tests/hermes_cli/test_gateway_multiplex_served_record.py b/tests/hermes_cli/test_gateway_multiplex_served_record.py index b70930424b..a0867c8f81 100644 --- a/tests/hermes_cli/test_gateway_multiplex_served_record.py +++ b/tests/hermes_cli/test_gateway_multiplex_served_record.py @@ -108,7 +108,10 @@ def test_dashboard_liveness_ladder_reports_served_profile_running(served_root): coder = served_root / "profiles" / "coder" live = resolve_gateway_liveness(profile_dir=coder, health_probe=None, use_cache=False) assert live.running is True and live.pid == os.getpid() and live.source == "multiplexer" - assert profile_platforms_from_multiplexer(live.runtime, "coder") == {"telegram": {"state": "connected"}} + plats = profile_platforms_from_multiplexer(live.runtime, "coder") + assert plats["telegram"] == {"state": "connected"} + # The default's api_server is coder's too (served at /p/coder/), not a missing adapter. + assert plats["api_server"]["state"] == "connected" # An unserved profile keeps the historical "stopped" answer. other = resolve_gateway_liveness(profile_dir=served_root / "profiles" / "other", health_probe=None, use_cache=False) assert other.running is False diff --git a/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py b/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py index 91d19c7c62..e3f859428b 100644 --- a/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py +++ b/tests/hermes_cli/test_pooled_served_profile_backend_unscoped.py @@ -43,7 +43,8 @@ def test_unscoped_liveness_in_a_served_profile_process_matches_the_scoped_answer scoped = resolve_gateway_liveness(profile_dir=alpha, health_probe=None, use_cache=False) unscoped = resolve_gateway_liveness(health_probe=None, use_cache=False) assert (unscoped.running, unscoped.pid, unscoped.source) == (scoped.running, scoped.pid, "multiplexer") - assert profile_platforms_from_multiplexer(unscoped.runtime, "alpha") == {"telegram": {"state": "connected"}} + plats = profile_platforms_from_multiplexer(unscoped.runtime, "alpha") + assert plats["telegram"] == {"state": "connected"} and plats["api_server"]["state"] == "connected" def test_unscoped_lifecycle_verbs_in_a_served_profile_process_address_the_multiplexer(pooled_served_process): diff --git a/tests/hermes_cli/test_served_profile_mirror_platforms.py b/tests/hermes_cli/test_served_profile_mirror_platforms.py new file mode 100644 index 0000000000..3344ca667e --- /dev/null +++ b/tests/hermes_cli/test_served_profile_mirror_platforms.py @@ -0,0 +1,62 @@ +"""A served secondary's api_server/webhook are MIRRORS of the default's listener (``/p//...``), +never adapters of their own, so the multiplexer record has no ``:api_server`` entry. Every +status reader used to fall through to ``pending_restart``: the Desktop Messaging card and Command +Center read "Restart needed" forever for a platform that was answering. The mirror must project as the +default's live state plus the URL the client has to call; ``/api/status?profile=`` names the profiles a +restart of the shared gateway would blip. +""" + +from __future__ import annotations + +import json +import os + +import pytest + + +@pytest.fixture +def served_root(tmp_path, monkeypatch): + root = tmp_path / "hermes" + (root / "profiles" / "alpha").mkdir(parents=True) + (root / "config.yaml").write_text("gateway: {multiplex_profiles: true}\n") + (root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)})) + (root / "gateway_state.json").write_text(json.dumps({ + "pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running", + "served_profiles": ["default", "alpha", "beta"], + "platforms": { + "api_server": {"state": "connected", "listener_base": "http://127.0.0.1:45719"}, + "webhook": {"state": "fatal", "error_code": "port_in_use"}, + "alpha:telegram": {"state": "connected"}, + }})) + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + import hermes_constants + monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + return root + + +def test_served_profile_projects_the_default_listener_mirrors_with_their_url(served_root): + from gateway.status import profile_platforms_from_multiplexer, resolve_gateway_liveness + alpha = served_root / "profiles" / "alpha" + live = resolve_gateway_liveness(profile_dir=alpha, health_probe=None, use_cache=False) + plats = profile_platforms_from_multiplexer(live.runtime, "alpha") + # The mirror inherits the default's live state and points at the profile's own prefix. + assert plats["api_server"]["state"] == "connected" + assert plats["api_server"]["ingress_url"] == "http://127.0.0.1:45719/p/alpha/v1" + # A dead default listener is dead for the profile too — never "connected" by fiat. + assert "webhook" not in plats + assert plats["telegram"] == {"state": "connected"} + # The default profile keeps its own un-prefixed entries; nothing is mirrored onto it. + assert "ingress_url" not in profile_platforms_from_multiplexer(live.runtime, "default").get("api_server", {}) + + +def test_messaging_card_for_a_served_profile_reads_connected_not_restart_needed(served_root, monkeypatch): + from hermes_cli.web_routers import messaging + monkeypatch.setattr(messaging, "_platform_enablement", lambda *a, **k: (True, True, None)) + entry = {"id": "api_server", "name": "API server", "description": "", "docs_url": "", "env_vars": [], + "required_env": []} + alpha = served_root / "profiles" / "alpha" + [payload] = messaging._platform_payloads(alpha, [entry]) + assert payload["gateway_running"] is True + assert payload["state"] == "connected", payload + assert payload["ingress_url"] == "http://127.0.0.1:45719/p/alpha/v1" diff --git a/web/src/App.tsx b/web/src/App.tsx index d0d50b697d..9a611a5399 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -105,6 +105,7 @@ import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; import { latchChatActivation } from "@/lib/chat-activation"; +import { sharedGatewayProfiles, sharedGatewayRestartDescription } from "@/lib/shared-gateway"; import { api } from "@/lib/api"; import type { StatusResponse, UpdateCheckResponse } from "@/lib/api"; @@ -943,6 +944,8 @@ function SidebarSystemActions({ const { activeAction, isBusy, isRunning, pendingAction, runAction } = useSystemActions(); const canUpdateHermes = status?.can_update_hermes === true; + // Served by the shared multiplexer: a restart blips every bot on this device — say which. + const sharedGateway = sharedGatewayProfiles(status); const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false); const [updateConfirmInfo, setUpdateConfirmInfo] = @@ -1076,17 +1079,21 @@ function SidebarSystemActions({ setRestartConfirmOpen(false)} onConfirm={confirmRestart} open={restartConfirmOpen} title={ - t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?` + sharedGateway + ? "Restart the shared gateway?" + : (t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`) } /> diff --git a/web/src/contexts/SystemActions.tsx b/web/src/contexts/SystemActions.tsx index 2dd05232c0..db92b36515 100644 --- a/web/src/contexts/SystemActions.tsx +++ b/web/src/contexts/SystemActions.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import type { ActionStatusResponse } from "@/lib/api"; import { Toast } from "@nous-research/ui/ui/components/toast"; +import { sharedGatewayProfiles, sharedGatewayRestartedMessage } from "@/lib/shared-gateway"; import { useI18n } from "@/i18n"; import { SystemActionsContext, @@ -44,10 +45,18 @@ export function SystemActionsProvider({ setActionStatus(resp); if (!resp.running) { const ok = resp.exit_code === 0; + // A restart of the shared multiplexer reconnected every bot on the device: name the count. + const shared = + ok && activeAction === "restart" + ? sharedGatewayProfiles(await api.getStatus().catch(() => null)) + : null; + if (cancelled) return; setToast({ type: ok ? "success" : "error", message: ok - ? t.status.actionFinished + ? shared + ? sharedGatewayRestartedMessage(shared.length) + : t.status.actionFinished : `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`, }); return; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4ac9e4e968..773b3c45b7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1912,6 +1912,10 @@ export interface StatusResponse { gateway_pid: number | null; gateway_platforms: Record; gateway_running: boolean; + /** Every profile the gateway process serves when the managed profile is carried by the + * shared multiplexer (e.g. ["default", "alpha", "beta"]); null/absent for a standalone + * gateway or an older backend. */ + gateway_shared_with?: string[] | null; gateway_state: string | null; gateway_updated_at: string | null; hermes_home: string; diff --git a/web/src/lib/shared-gateway.test.ts b/web/src/lib/shared-gateway.test.ts new file mode 100644 index 0000000000..8427bec1ea --- /dev/null +++ b/web/src/lib/shared-gateway.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + servedProfileRefusal, + sharedGatewayProfiles, +} from "./shared-gateway"; + +describe("sharedGatewayProfiles", () => { + it("names every bot on the shared multiplexer, default first", () => { + expect( + sharedGatewayProfiles({ gateway_shared_with: ["beta", "default", "alpha"] }), + ).toEqual(["default", "alpha", "beta"]); + }); + + it("is null for standalone gateways, older backends and a lone default", () => { + expect(sharedGatewayProfiles({ gateway_shared_with: null })).toBeNull(); + expect(sharedGatewayProfiles({})).toBeNull(); + expect(sharedGatewayProfiles({ gateway_shared_with: ["default"] })).toBeNull(); + }); +}); + +describe("servedProfileRefusal", () => { + it("unwraps the 409 detail into a sentence and ignores other failures", () => { + const err = new Error( + '409: {"detail":"The default gateway already serves profile \'alpha\' as a multiplexer; stop it from the default profile instead of a separate gateway for this profile."}', + ); + expect(servedProfileRefusal(err)).toMatch(/^The default gateway already serves profile 'alpha'/); + expect(servedProfileRefusal(new Error("500: boom"))).toBeNull(); + }); +}); diff --git a/web/src/lib/shared-gateway.ts b/web/src/lib/shared-gateway.ts new file mode 100644 index 0000000000..cbaba33bc5 --- /dev/null +++ b/web/src/lib/shared-gateway.ts @@ -0,0 +1,33 @@ +import type { StatusResponse } from "@/lib/api"; + +/** Profiles a gateway restart would blip when the managed profile is carried by the shared + * multiplexer (default first). `null` for a standalone gateway or an older backend without + * `gateway_shared_with`; those keep the plain restart copy. */ +export function sharedGatewayProfiles( + status: Pick | null | undefined, +): string[] | null { + const shared = status?.gateway_shared_with; + if (!Array.isArray(shared)) return null; + const names = [...new Set(shared.map((n) => String(n).trim()).filter(Boolean))]; + if (names.length < 2) return null; + return names.sort((a, b) => + a === "default" ? -1 : b === "default" ? 1 : a.localeCompare(b), + ); +} + +export function sharedGatewayRestartDescription(profiles: string[]): string { + return `All bots on this device reconnect: ${profiles.join(", ")}`; +} + +export function sharedGatewayRestartedMessage(count: number): string { + return `Shared gateway restarted (${count} ${count === 1 ? "bot" : "bots"})`; +} + +/** The REST layer throws `": "`; a 409 on gateway start/stop for a served profile + * carries the multiplexer explanation in `detail`. Return it as a plain sentence, else null. */ +export function servedProfileRefusal(error: unknown): string | null { + const text = error instanceof Error ? error.message : String(error ?? ""); + if (!text.startsWith("409")) return null; + const detail = text.match(/"detail"\s*:\s*"([^"]+)"/)?.[1]; + return detail ?? text.replace(/^409:\s*/, ""); +} diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index d70ec26203..04cb87904c 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -46,6 +46,12 @@ import { HermesConsoleModal } from "@/components/HermesConsoleModal"; import { cn, themedBody } from "@/lib/utils"; import { api } from "@/lib/api"; import { copyTextToClipboard } from "@/lib/clipboard"; +import { + servedProfileRefusal, + sharedGatewayProfiles, + sharedGatewayRestartDescription, + sharedGatewayRestartedMessage, +} from "@/lib/shared-gateway"; import type { StatusResponse, MemoryStatus, @@ -290,7 +296,14 @@ export default function SystemPage() { }, [loadAll]); // ── Gateway lifecycle ────────────────────────────────────────────── + // A profile served by the shared multiplexer has no gateway of its own: Restart restarts + // the ONE process every bot on this device runs in, so confirm first and say so after; + // Start/Stop answer 409 with an explanation that belongs in a notice, not a raw error. + const sharedGateway = sharedGatewayProfiles(status); + const [sharedRestartOpen, setSharedRestartOpen] = useState(false); + const [servedNotice, setServedNotice] = useState(null); const runGateway = async (verb: "start" | "stop" | "restart") => { + setServedNotice(null); try { if (verb === "start") { await api.startGateway(); @@ -305,9 +318,21 @@ export default function SystemPage() { showToast(`Gateway ${verb} started`, "success"); setTimeout(loadAll, 3000); } catch (e) { + const refusal = servedProfileRefusal(e); + if (refusal) { + setServedNotice(refusal); + return; + } showToast(`Gateway ${verb} failed: ${e}`, "error"); } }; + const requestRestart = () => { + if (sharedGateway) { + setSharedRestartOpen(true); + return; + } + void runGateway("restart"); + }; const migrateToMultiplex = async () => { try { @@ -420,6 +445,9 @@ export default function SystemPage() { const handleActionComplete = useCallback( (action: string, exitCode: number | null) => { + if (action === "gateway-restart" && exitCode === 0 && sharedGateway) { + showToast(sharedGatewayRestartedMessage(sharedGateway.length), "success"); + } if (action === "backup" && pendingBackupArchive) { if (exitCode === 0) { setDownloadableBackupArchive(pendingBackupArchive); @@ -429,7 +457,7 @@ export default function SystemPage() { } } }, - [pendingBackupArchive, showToast], + [pendingBackupArchive, sharedGateway, showToast], ); const downloadBackup = async () => { @@ -673,6 +701,18 @@ export default function SystemPage() { }} /> + setSharedRestartOpen(false)} + onConfirm={() => { + setSharedRestartOpen(false); + void runGateway("restart"); + }} + title="Restart the shared gateway?" + description={sharedGatewayRestartDescription(sharedGateway ?? [])} + confirmLabel="Restart all" + /> + setUpdateConfirmOpen(false)} @@ -1079,7 +1119,7 @@ export default function SystemPage() { + {(sharedGateway || servedNotice) && ( + + {servedNotice ?? `Served by the shared gateway with ${sharedGateway!.join(", ")}.`} + + )} {migratePlan && !migratePlan.already_multiplexed && migratePlan.profiles.length > 1 && ( migratePlan.eligible || migratePlan.blockers.length > 0 ) && ( diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index e2f562ad98..b3045093bd 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -130,9 +130,15 @@ loop. `hermes -p coder gateway stop` refuses the same way (exit 78) when coder h gateway of its own — there is nothing to stop but the multiplexer, which `hermes gateway stop` on the default profile takes down for every served profile. The dashboard and Desktop app follow the CLI: for a served profile the "Start" and -"Stop" gateway actions answer `409` with the same explanation, and "Restart" -restarts the multiplexer (the process that actually serves the profile) instead of -spawning a `-p coder gateway restart` that could only fail. +"Stop" gateway actions answer `409` with the same explanation (rendered as an inline +notice on the System page), and "Restart" restarts the multiplexer (the process that +actually serves the profile) instead of spawning a `-p coder gateway restart` that +could only fail. Because that restart reconnects every bot on the device, both apps +first ask *"Restart the shared gateway? All bots on this device reconnect: default, +coder, research"* (the list is the running gateway's `served_profiles`) and report +*"Shared gateway restarted (3 bots)"* when it completes. A standalone profile keeps +the plain restart. `/api/status?profile=coder` carries the same list as +`gateway_shared_with` (null for a standalone gateway). "Served" is read from the running gateway's own record (`served_profiles` in the default home's `gateway_state.json`), so it stays correct when the multiplexer was enabled only through `GATEWAY_MULTIPLEX_PROFILES` in the default profile's @@ -253,8 +259,13 @@ Inbound callback URLs on the shared listener: ``` `hermes gateway status` and `hermes status` on the default profile list the same -URLs per served profile, and the dashboard's Channels page shows them as each -platform's `ingress_url` when viewing that profile. A per-profile +URLs per served profile, and the dashboard's Channels page and the Desktop +Messaging page show them as each platform's `ingress_url` when viewing that +profile. The default's own `api_server` and `webhook` are reported the same way +for a served profile — as **connected** with `ingress_url` +`http://127.0.0.1:8642/p/coder/v1` (respectively `.../p/coder/webhooks/`) — +since the profile has no adapter of its own for them; it is the default's listener +answering under the `/p/coder/` prefix. A per-profile `SMS_WEBHOOK_PORT`, `LINE_PORT`, `TEAMS_PORT`, … in a secondary's `.env` is ignored under the multiplexer (nothing binds); it applies again the moment that profile runs its own standalone gateway.