Files
hermes-agent/hermes_cli/gateway_multiplex_served.py
T
teknium1 6a66a5d481 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.
2026-09-12 12:52:19 -07:00

101 lines
5.3 KiB
Python

"""Which profiles does the LIVE default multiplexer serve? One answer for every CLI/dashboard surface.
``gateway/run_adapters.py::_record_served_profiles`` writes ``served_profiles`` into the default
home's ``gateway_state.json`` at startup. That record is the truth about the running process; the
default ``config.yaml`` plus ``GATEWAY_MULTIPLEX_PROFILES`` as seen by the *CLI* process is only a
guess (``hermes -p coder ...`` loads coder's ``.env``, so an env-only opt-in on the default profile
is invisible to it, and an allowlist edited after start flips the guess before the restart).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def live_default_gateway_pid() -> Optional[int]:
"""PID of the default profile's gateway when its pid record names a live process, else None."""
from hermes_constants import get_default_hermes_root
from gateway.status import _pid_exists, _pid_from_record, _read_pid_record
rec = _read_pid_record(get_default_hermes_root() / "gateway.pid")
pid = _pid_from_record(rec) if rec else None
return pid if pid and _pid_exists(pid) else None
def recorded_served_profiles(default_root: Optional[Path] = None) -> Optional[list[str]]:
"""``served_profiles`` the live default gateway recorded, or None when the key is absent (a record
from before the multiplexer recorded it, or a stopped/absent gateway). Callers fall back to config
derivation only on None: an empty list is an authoritative "serves nobody else"."""
from hermes_constants import get_default_hermes_root
from gateway.status import read_runtime_status
if live_default_gateway_pid() is None:
return None
runtime = read_runtime_status((default_root or get_default_hermes_root()) / "gateway_state.json")
served = (runtime or {}).get("served_profiles")
return [str(p) for p in served] if isinstance(served, list) else None
def multiplexer_served_secondaries() -> list[str]:
"""Named profiles the live default multiplexer serves (excludes ``default``); empty when none."""
return [p for p in (recorded_served_profiles() or []) if p and p != "default"]
def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict[str, str]]:
"""``{profile: {platform: url}}`` for every secondary inbound-port platform the live multiplexer
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, shared_listener_mirror_platforms
if live_default_gateway_pid() is None:
return {}
runtime = read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {}
platforms = runtime.get("platforms")
if not isinstance(platforms, dict):
return {}
urls: dict[str, dict[str, str]] = {}
for key, entry in platforms.items():
if not (isinstance(key, str) and ":" in key and isinstance(entry, dict)):
continue
url = entry.get("ingress_url")
if not url or entry.get("state") in ("fatal", "disconnected", "stopped"):
continue
name, platform = key.split(":", 1)
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
def format_ingress_url_lines(urls: dict[str, str], indent: str = " ") -> list[str]:
"""One ``<indent><platform>: <url>`` line per platform, sorted."""
return [f"{indent}{platform}: {url}" for platform, url in sorted(urls.items())]
def notify_multiplexer_profiles_changed(profile_name: str, *, timeout: float = 8.0) -> Optional[list[str]]:
"""Tell the live default multiplexer that ``profiles/`` changed (``profile_name`` was created or
deleted) so it hot-serves / unroutes it now instead of at its next periodic rescan. Returns the
served-profile list the gateway answered with, or None when no multiplexer answered (no live default
gateway, single-profile gateway, or a gateway predating the verb). Never raises."""
try:
from hermes_constants import get_default_hermes_root
from gateway.control_socket import rescan_gateway_profiles
if live_default_gateway_pid() is None:
return None
answer = rescan_gateway_profiles(get_default_hermes_root(), timeout=timeout)
except Exception:
logger.debug("multiplexer rescan notification failed for %r", profile_name, exc_info=True)
return None
if not isinstance(answer, dict) or answer.get("multiplex") is False:
return None
served = answer.get("served_profiles")
return [str(p) for p in served] if isinstance(served, list) else None