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:
@@ -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:
|
||||
|
||||
@@ -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())]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user