febd2af391 skipped WhatsApp on every secondary profile under multiplex on the
premise that "a secondary has no credential of its own to bring" because the
bridge is "one authenticated session tied to a single phone number". The
session directory is home-relative (`platforms/whatsapp/session` under the
profile's own home), so each profile already owns its own paired session.
What actually collided was the bridge port, 3000 for everyone, and the
adapter's startup path that kills whatever holds that port before spawning.
A fleet running one phone per profile lost WhatsApp on every profile but the
launching one, with a single INFO line to say so.
The credential gate for WhatsApp is now the profile's own paired session
(`creds.json` in its session directory), evaluated in that profile's scope.
An unpaired secondary is reported as `whatsapp_unpaired` with the pairing
command, once, no retry loop. A paired secondary gets an adapter of its own.
Port ownership without any probe of another profile's bridge
(`plugins/platforms/whatsapp/bridge_ownership.py`): an explicit
`platforms.whatsapp.extra.bridge_port` wins; otherwise a secondary takes the
first free port in 3001..3099 and records it in
`platforms/whatsapp/bridge_port` so it is stable across restarts. A
secondary never calls `_kill_port_process`, never adopts a running bridge
and never signals a pidfile process it did not spawn; a bound port or a
live prior process is a fatal for that profile only, naming the port and
the config key. The default profile's startup path is unchanged.
Out-of-process sends (`hermes -p X whatsapp send`) read the same record so
they reach the profile's own bridge.
Relay stays process-level shared ingress; its skip and remedy are untouched.
(cherry picked from commit f96c53df65cff45af2488bd13f22225ce1aec743)
127 lines
6.7 KiB
Python
127 lines
6.7 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 a VERIFIED live process owns it, else None.
|
|
|
|
``gateway.status.live_gateway_pid_for_home``: pid file + lock, then the runtime record the gateway
|
|
itself writes, each proven against the live process (start time, gateway command line, home). A
|
|
launch-service gateway can be live with no ``gateway.pid`` at all, and a stale record whose PID was
|
|
recycled by an unrelated process must not make its ``served_profiles`` authoritative. Never key this
|
|
off the record's ``updated_at``: an idle gateway never advances it.
|
|
"""
|
|
from hermes_constants import get_default_hermes_root
|
|
from gateway.status import live_gateway_pid_for_home
|
|
return live_gateway_pid_for_home(get_default_hermes_root())
|
|
|
|
|
|
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_unserved_platforms(profile: str) -> dict[str, str]:
|
|
"""``{platform: reason}`` for a served profile's platforms the multiplexer deliberately does not run
|
|
(unpaired WhatsApp or Relay shared ingress; ``gateway.run_adapters`` stamps
|
|
``<profile>:<platform>`` as ``disabled`` with a reason and remedy)."""
|
|
from hermes_constants import get_default_hermes_root
|
|
from gateway.status import read_runtime_status
|
|
if not profile or 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 {}
|
|
prefix = f"{profile}:"
|
|
return {
|
|
key[len(prefix):]: str(entry.get("error_message") or "not served under multiplex")
|
|
for key, entry in platforms.items()
|
|
if isinstance(key, str) and key.startswith(prefix) and isinstance(entry, dict)
|
|
and entry.get("error_code") in {"multiplex_shared_ingress", "whatsapp_unpaired"}
|
|
}
|
|
|
|
|
|
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
|