feat(status): show each served profile's shared-listener callback URLs

hermes -p <name> gateway status, hermes gateway status and hermes status (under
Serves:) list the /p/<profile>/<path> URL per inbound-port platform the live
multiplexer serves, read from the <profile>:<platform> ingress_url in the default
home's gateway_state.json (hermes_cli/gateway_multiplex_served.py). The dashboard's
messaging payload carries the same ingress_url and the Channels page renders it.
The dashboard's 409 guard now covers only api_server/webhook (the mirrored pair):
enabling Twilio/LINE/Teams/... on a secondary is allowed because the gateway serves
it.
This commit is contained in:
Teknium
2026-09-12 01:39:20 -07:00
parent 6fc76ce751
commit 65fd3a2b9c
6 changed files with 77 additions and 14 deletions
+21
View File
@@ -1477,6 +1477,23 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:
print(" can refuse to start another copy until this process stops.")
def _print_served_ingress_urls(profile: str | None = None) -> None:
"""Callback URLs of inbound-port platforms the live multiplexer serves for secondary profiles
(the value to paste into the Twilio / LINE / Teams / BlueBubbles console)."""
try:
from hermes_cli.gateway_multiplex_served import format_ingress_url_lines, served_profile_ingress_urls
urls = served_profile_ingress_urls(profile)
except Exception:
return
if not urls:
return
print()
print("Inbound callback URLs on the shared listener:")
for name, per_platform in sorted(urls.items()):
for line in format_ingress_url_lines(per_platform, indent=f" {name}/" if not profile else " "):
print(line)
def _print_other_profiles_gateway_status() -> None:
"""Print other profiles' running gateways at the bottom of ``hermes gateway status``."""
try:
@@ -6295,12 +6312,14 @@ def _cmd_status(args):
full = getattr(args, "full", False)
system = getattr(args, "system", False)
snapshot = get_gateway_runtime_snapshot(system=system)
from hermes_cli.profiles import get_active_profile_name
_windows_service_installed = is_windows() and _gw_windows().is_installed()
if not snapshot.running and named_profile_served_by_running_multiplexer():
# Satellite profile: the default multiplexer is the live inbound process for it.
print("✓ Gateway is running via the default-profile multiplexer")
print(" Manage it from the default profile: hermes gateway status")
_print_served_ingress_urls(get_active_profile_name())
elif (kind := _installed_service_kind_for(lambda: _windows_service_installed)) is not None:
if kind == "systemd":
systemd_status(deep, system=system, full=full)
@@ -6309,12 +6328,14 @@ def _cmd_status(args):
else:
_gw_windows().status(deep=deep)
_print_gateway_process_mismatch(snapshot)
_print_served_ingress_urls()
else:
pids = list(snapshot.gateway_pids)
if pids:
print(f"✓ Gateway is running (PID: {', '.join(map(str, pids))})")
print(" (Running manually, not as a system service)")
_print_runtime_health()
_print_served_ingress_urls()
print()
_print_lines(*_STATUS_RUNNING_HINTS[_status_host_kind()])
else:
+31
View File
@@ -41,3 +41,34 @@ def recorded_served_profiles(default_root: Optional[Path] = None) -> Optional[li
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
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)
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())]
+4
View File
@@ -234,6 +234,10 @@ def _render_gateway(ctx):
_kv("PID(s):", _format_gateway_pids(snapshot.gateway_pids))
if snapshot.running and (served := multiplexer_served_secondaries()):
_kv("Serves:", ", ".join(served))
from hermes_cli.gateway_multiplex_served import served_profile_ingress_urls
for name, per_platform in sorted(served_profile_ingress_urls().items()):
for platform, url in sorted(per_platform.items()):
_kv(f" {name}/{platform}:", url)
if snapshot.has_process_service_mismatch:
_kv("Service:", "installed but not managing the current running gateway")
elif _is_termux() and not snapshot.gateway_pids:
+13 -14
View File
@@ -259,6 +259,8 @@ def _messaging_platform_payload(
"gateway_running": gateway_running, "state": state, "error_code": error_code,
"error_message": error_message, "updated_at": runtime_platform.get("updated_at"),
"home_channel": home_channel, "env_vars": env_vars,
# Multiplex secondary served on the default's shared listener: the vendor callback URL.
"ingress_url": runtime_platform.get("ingress_url") if gateway_running else None,
}
if platform_id == "whatsapp":
whatsapp_mode = env_value("WHATSAPP_MODE").strip()
@@ -794,19 +796,17 @@ async def get_messaging_platforms(profile: Optional[str] = None):
def _multiplex_port_binding_conflict(platform_id: str, requested_profile: Optional[str]) -> Optional[str]:
"""Reason enabling ``platform_id`` on the target profile would break a
multiplexed gateway, or ``None`` when allowed.
"""Reason enabling ``platform_id`` on the target profile is pointless under a multiplexed
gateway, or ``None`` when allowed.
Mirrors ``_start_one_profile_adapters`` (gateway/run.py): with
``gateway.multiplex_profiles`` on, the default profile owns the single shared
HTTP listener (``/p/<profile>/``), so a SECONDARY profile must never enable a
port-binding platform or the shared gateway dies with ``MultiplexConfigError``
for ALL profiles. Only *enabling* is blocked; disabling/clearing stays allowed
so users can repair an invalid profile.
With ``gateway.multiplex_profiles`` on, the default profile's listener already mirrors
``api_server`` and ``webhook`` at ``/p/<profile>/`` for every profile, so a SECONDARY must not
enable a second one. Every other inbound-port platform (Twilio, LINE, Teams, ...) IS allowed on a
secondary: the gateway serves it on the shared listener at ``/p/<profile>/<path>``.
"""
from gateway.config import PORT_BINDING_PLATFORM_VALUES, load_gateway_config
from gateway.config import SHARED_LISTENER_MIRROR_PLATFORMS, load_gateway_config
if platform_id not in PORT_BINDING_PLATFORM_VALUES:
if platform_id not in SHARED_LISTENER_MIRROR_PLATFORMS:
return None
requested = (requested_profile or "").strip()
@@ -829,10 +829,9 @@ def _multiplex_port_binding_conflict(platform_id: str, requested_profile: Option
return None
return (
f"Cannot enable '{platform_id}' on profile '{target}': it binds its own listener port, "
"and gateway.multiplex_profiles is on, so the default profile owns the single shared HTTP "
"listener for every profile. Configure this channel on the default profile instead "
"(disabling or clearing it here is still allowed)."
f"Cannot enable '{platform_id}' on profile '{target}': gateway.multiplex_profiles is on and the "
f"default profile's listener already serves it for every profile at /p/{target}/. Configure it "
"on the default profile instead (disabling or clearing it here is still allowed)."
)
+2
View File
@@ -1590,6 +1590,8 @@ export interface MessagingPlatform {
error_message: string | null;
updated_at: string | null;
home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null;
/** Multiplex secondary served on the default profile's shared listener: the vendor callback URL. */
ingress_url?: string | null;
whatsapp_setup?: {
mode?: string;
allowed_users_set?: boolean;
+6
View File
@@ -567,6 +567,12 @@ export default function ChannelsPage() {
{platform.error_message}
</span>
)}
{platform.ingress_url && (
<span className="text-xs text-muted-foreground break-all">
Callback URL (shared listener):{" "}
<code className="font-mono">{platform.ingress_url}</code>
</span>
)}
</div>
</div>