fix(desktop,dashboard): served profile's api_server/webhook read connected with their /p/<profile>/ URL; shared-gateway restart asks first

Under gateway.multiplex_profiles a secondary's api_server and webhook are never built as
adapters (run_adapters skips SHARED_LISTENER_MIRROR_PLATFORMS: the default's listener answers
/p/<profile>/...). The multiplexer record therefore has no `<profile>:api_server` entry,
profile_platforms_from_multiplexer() returned {} for them and both /api/messaging/platforms
and /api/status?profile= fell through to `pending_restart`: the Desktop Messaging card and
Command Center said "Restart needed" forever for a platform that was answering.

- gateway.status.shared_listener_mirror_platforms projects the default's LIVE api_server /
  webhook entry onto every served secondary with `ingress_url` = `<listener>/p/<profile>/v1`
  (`.../webhooks/<route>`); a dead default listener is not mirrored. The api_server / webhook
  adapters stamp the listener they actually bound (`listener_base`) on connect so the URL is
  the real one, not a config guess. `hermes status` lists those URLs beside the other
  shared-ingress platforms.
- /api/status?profile= reports `gateway_shared_with` (every profile the multiplexer carries)
  when the served rung answered; null for a standalone gateway.
- Desktop: the messaging card shows the URL line; "Restart gateway" from a served profile
  (statusbar menu, Cmd+K, messaging/webhooks banners, Command Center) confirms "Restart the
  shared gateway? All bots on this device reconnect: default, alpha, beta" (Restart all /
  Cancel) and toasts "Shared gateway restarted (3 bots)". Standalone keeps the silent path.
- Dashboard: same confirm + toast on the System page and the sidebar restart; the 409 from
  start/stop on a served profile renders as an inline notice instead of a raw error toast.
This commit is contained in:
teknium1
2026-09-12 08:21:47 -07:00
committed by Teknium
parent 819988acb7
commit 6a66a5d481
31 changed files with 446 additions and 31 deletions
@@ -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,
+13
View File
@@ -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/<profile>/: the state pill says connected, this line says where to point the client.
if (platform.ingress_url) {
return (
<p className="mt-2 text-xs leading-5 text-muted-foreground break-all">
{t.messaging.sharedListenerUrl}{' '}
<code className="font-mono text-foreground" data-slot="ingress-url">
{platform.ingress_url}
</code>
</p>
)
}
if (!platform.enabled || platform.state === 'connected') {
return null
}
+5
View File
@@ -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: 'إعادة التشغيل الآن',
+5
View File
@@ -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',
+5
View File
@@ -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: '今すぐ再起動',
+5
View File
@@ -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: 'Нужна настройка',
+5
View File
@@ -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<string, string>
unknown: string
hintPendingRestart: string
sharedListenerUrl: string
hintGatewayStopped: string
credentialsSet: string
needsSetup: string
+5
View File
@@ -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: '立即重新啟動',
+5
View File
@@ -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: '需要设置',
@@ -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()
})
})
@@ -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<StatusResponse, 'gateway_shared_with'> | 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)))
}
+45 -2
View File
@@ -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<void> {
}
}
// 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<false | null | string[]> {
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<void> {
// 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<boolean> {
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'))
+6
View File
@@ -280,6 +280,9 @@ export interface MessagingPlatformInfo {
gateway_running: boolean
home_channel?: MessagingHomeChannel | null
id: string
/** Served secondary under a multiplexed gateway: the /p/<profile>/ 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<string, PlatformStatus>
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
+2
View File
@@ -249,6 +249,8 @@ PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {"feishu": "webhook"}
# Port-binders whose /p/<profile>/ surface is a MIRROR served by the default's own adapter; a secondary
# never gets an instance of these (api_server: /p/<profile>/v1/..., webhook: profile-bound routes).
SHARED_LISTENER_MIRROR_PLATFORMS = frozenset({"api_server", "webhook"})
# Path a client appends to ``<default listener>/p/<profile>`` to reach each mirror.
SHARED_LISTENER_MIRROR_PATHS: dict[str, str] = {"api_server": "/v1", "webhook": "/webhooks/<route>"}
def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool:
+2 -1
View File
@@ -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)
+7 -2
View File
@@ -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/<profile>/`` 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."""
+9 -5
View File
@@ -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
+2 -1
View File
@@ -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)
+35 -4
View File
@@ -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/<profile>/ 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 (``<listener_base>/p/<profile>/...``).
("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 ``<profile>:api_server``
entry and every reader fell through to ``pending_restart`` — "Restart needed" forever while
``/p/<profile>/v1/...`` answered. Only a live default entry is mirrored; its state is the profile's
state, plus the ``/p/<profile>`` 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 ``<profile>:<platform>`` 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(
+7 -1
View File
@@ -48,7 +48,7 @@ def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict
serves on its shared listener (``<profile>:<platform>`` 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/<profile>/ (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
+7 -1
View File
@@ -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,
@@ -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
@@ -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):
@@ -0,0 +1,62 @@
"""A served secondary's api_server/webhook are MIRRORS of the default's listener (``/p/<profile>/...``),
never adapters of their own, so the multiplexer record has no ``<profile>: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"
+11 -4
View File
@@ -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({
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.restartGateway}
confirmLabel={sharedGateway ? "Restart all" : t.status.restartGateway}
description={
t.status.restartGatewayConfirmMessage ??
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward."
sharedGateway
? sharedGatewayRestartDescription(sharedGateway)
: (t.status.restartGatewayConfirmMessage ??
"This restarts the Hermes gateway process. Connected channels and active sessions will reconnect afterward.")
}
loading={pendingAction === "restart"}
onCancel={() => setRestartConfirmOpen(false)}
onConfirm={confirmRestart}
open={restartConfirmOpen}
title={
t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`
sharedGateway
? "Restart the shared gateway?"
: (t.status.restartGatewayConfirmTitle ?? `${t.status.restartGateway}?`)
}
/>
+10 -1
View File
@@ -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;
+4
View File
@@ -1912,6 +1912,10 @@ export interface StatusResponse {
gateway_pid: number | null;
gateway_platforms: Record<string, PlatformStatus>;
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;
+30
View File
@@ -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();
});
});
+33
View File
@@ -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<StatusResponse, "gateway_shared_with"> | 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 `"<status>: <body>"`; 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*/, "");
}
+47 -2
View File
@@ -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<string | null>(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() {
}}
/>
<ConfirmDialog
open={sharedRestartOpen}
onCancel={() => setSharedRestartOpen(false)}
onConfirm={() => {
setSharedRestartOpen(false);
void runGateway("restart");
}}
title="Restart the shared gateway?"
description={sharedGatewayRestartDescription(sharedGateway ?? [])}
confirmLabel="Restart all"
/>
<ConfirmDialog
open={canUpdateHermes && updateConfirmOpen}
onCancel={() => setUpdateConfirmOpen(false)}
@@ -1079,7 +1119,7 @@ export default function SystemPage() {
<Button
size="sm"
className="uppercase"
onClick={() => runGateway("restart")}
onClick={requestRestart}
prefix={<RotateCw className="h-3.5 w-3.5" />}
>
Restart
@@ -1096,6 +1136,11 @@ export default function SystemPage() {
</Button>
</div>
</CardContent>
{(sharedGateway || servedNotice) && (
<CardContent className="border-t border-current/10 py-3 text-xs text-muted-foreground" data-slot="shared-gateway-notice">
{servedNotice ?? `Served by the shared gateway with ${sharedGateway!.join(", ")}.`}
</CardContent>
)}
{migratePlan && !migratePlan.already_multiplexed && migratePlan.profiles.length > 1 && (
migratePlan.eligible || migratePlan.blockers.length > 0
) && (
@@ -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/<route>`) —
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.