Files
hermes-agent/hermes_cli/relay_plugin_migrate.py
teknium1 395e4248d0 fix(gateway): surface secondary WhatsApp/Relay skipped under multiplex instead of a silent continue
Under gateway.multiplex_profiles, `_start_one_profile_adapters` skipped
Platform.RELAY / Platform.WHATSAPP for secondaries with a bare `continue`,
and the startup "not being served" WARNING only covered platforms the
PRIMARY skipped. Four secondaries on one live box had WHATSAPP_ENABLED=true
and nothing in the log, status file, or `hermes gateway status` said the
channel was dead.

- `_note_unserved_secondary_platform`: one INFO per (profile, platform)
  naming the reason (shared process-level ingress owned by the default) and
  the remedy (enable it on the default profile, or disable it here), plus a
  `<profile>:<platform>` runtime-status stamp (state=disabled,
  error_code=multiplex_shared_ingress).
- `_start_secondary_profiles` folds those platforms into the loud WARNING
  when NO profile (default included) runs them.
- `hermes gateway status --profile X` prints
  `whatsapp: not served under multiplex (shared ingress owned by default)`
  from that stamp; /api/status excludes `disabled` entries from the
  platforms degraded verdict (informational, not a fault).
- Docs: multi-profile-gateways.md gets the shared-ingress rule.
2026-09-15 03:44:36 -07:00

262 lines
12 KiB
Python

"""Migrate legacy ``HERMES_NEMO_RELAY_ATIF_*`` / ``ATOF_*`` exporter vars into a Relay ``plugins.toml``.
The Relay cutover (Aug 2026) stopped honouring the legacy exporter variables: a profile that still
carries them and no ``HERMES_NEMO_RELAY_PLUGINS_TOML`` logs one warning and initialises NO exporters,
so users who followed the earlier docs lost every trace silently. This module turns those variables
into ``<profile home>/relay-plugins.toml`` (built from the ``nemo_relay.observability`` dataclasses so
the file is exactly what Relay validates), points ``HERMES_NEMO_RELAY_PLUGINS_TOML`` at it, and
comments the legacy lines out. It runs from ``hermes update`` for every profile home and from
``hermes relay migrate`` for the active one.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Optional
from hermes_cli.relay_plugin_cutover import (
LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV, configured_legacy_relay_env_vars)
logger = logging.getLogger(__name__)
RELAY_PLUGINS_TOML_NAME = "relay-plugins.toml"
_TRUE = {"1", "true", "yes", "on"}
@dataclass
class RelayMigrationResult:
home: Path
toml_path: Optional[Path] = None
migrated_vars: tuple[str, ...] = ()
skipped_reason: Optional[str] = None
validation_error: Optional[str] = None
diagnostics: list = field(default_factory=list)
@property
def migrated(self) -> bool:
return self.toml_path is not None and self.skipped_reason is None
def _truthy(value: Any) -> bool:
return str(value or "").strip().lower() in _TRUE
def _s(env: Mapping[str, Any], name: str, default: str = "") -> str:
return str(env.get(name) or "").strip() or default
def relay_plugin_payload_from_legacy_env(env: Mapping[str, Any]) -> dict[str, Any]:
"""The plugins.toml document (as a dict) equivalent to the legacy exporter variables. Defaults
mirror the removed ``plugins/observability/nemo_relay`` plugin so a migrated user keeps the
same files in the same places. Built through ``nemo_relay.observability`` so the sink
discriminator (``type = "file"``) and every field name are the ones Relay validates."""
from nemo_relay import observability as obs
atif = atof = None
if _truthy(env.get("HERMES_NEMO_RELAY_ATIF_ENABLED")):
kwargs: dict[str, Any] = {
"enabled": True,
"agent_name": _s(env, "HERMES_NEMO_RELAY_ATIF_AGENT_NAME", "Hermes Agent"),
"model_name": _s(env, "HERMES_NEMO_RELAY_ATIF_MODEL_NAME", "unknown"),
"filename_template": _s(env, "HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE", "hermes-atif-{session_id}.json"),
}
if _s(env, "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY"):
kwargs["output_directory"] = _s(env, "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY")
if _s(env, "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION"):
kwargs["agent_version"] = _s(env, "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION")
atif = obs.AtifConfig(**kwargs)
if _truthy(env.get("HERMES_NEMO_RELAY_ATOF_ENABLED")):
mode = _s(env, "HERMES_NEMO_RELAY_ATOF_MODE", "append").lower()
sink = obs.AtofFileSinkConfig(
output_directory=_s(env, "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY") or None,
filename=_s(env, "HERMES_NEMO_RELAY_ATOF_FILENAME", "hermes-atof.jsonl"),
mode="overwrite" if mode == "overwrite" else "append",
)
atof = obs.AtofConfig(enabled=True, sinks=[sink])
spec = obs.ComponentSpec(config=obs.ObservabilityConfig(atif=atif, atof=atof))
return {"version": 1, "components": [spec.to_dict()]}
def _toml_scalar(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
text = str(value).replace("\\", "\\\\").replace('"', '\\"')
return f'"{text}"'
def _emit_table(lines: list[str], path: str, table: Mapping[str, Any], *, array_item: bool = False) -> None:
"""Minimal TOML emitter for the nested-dict/list-of-dict shape ``to_dict()`` produces."""
scalars = {k: v for k, v in table.items() if not isinstance(v, (dict, list))}
nested = {k: v for k, v in table.items() if isinstance(v, (dict, list))}
if path:
lines.append(f"[[{path}]]" if array_item else f"[{path}]")
for key, value in scalars.items():
if value is not None:
lines.append(f"{key} = {_toml_scalar(value)}")
for key, value in nested.items():
child = f"{path}.{key}" if path else key
if isinstance(value, dict):
lines.append("")
_emit_table(lines, child, value)
else:
for item in value:
if isinstance(item, dict):
lines.append("")
_emit_table(lines, child, item, array_item=True)
else:
raise ValueError(f"unsupported TOML array item at {child}: {item!r}")
def dumps_toml(document: Mapping[str, Any]) -> str:
"""Serialize via ``tomli_w`` when installed, else the minimal emitter above (nested tables + arrays
of tables only — exactly the ``ComponentSpec.to_dict()`` shape)."""
try:
import tomli_w # type: ignore
return tomli_w.dumps(dict(document))
except ImportError:
lines: list[str] = []
_emit_table(lines, "", document)
return "\n".join(lines).strip() + "\n"
def validate_relay_plugin_payload(payload: Mapping[str, Any]) -> list:
"""Activate the payload once through Relay's own validator and clear it; returns the diagnostics
(empty = clean). Raises when Relay rejects the document outright."""
import asyncio
from nemo_relay import plugin
async def _probe():
try:
report = await plugin.initialize(dict(payload))
finally:
await plugin.clear_async()
return list((report or {}).get("diagnostics") or []) if isinstance(report, dict) else []
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_probe())
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(lambda: asyncio.run(_probe())).result()
def _comment_out_legacy_lines(lines: list[str], names: set[str]) -> list[str]:
from hermes_cli.config import _env_line_defines_key
out = []
for line in lines:
if any(_env_line_defines_key(line, name) for name in names):
out.append(f"# migrated to {RELAY_PLUGINS_TOML_NAME}: {line.rstrip()}\n")
else:
out.append(line)
return out
def migrate_profile_relay_env(home: Path, *, validate: bool = True) -> RelayMigrationResult:
"""Migrate ONE profile home's ``.env``. Never raises for a no-op; a Relay import/validation failure
leaves ``.env`` untouched and is reported in ``validation_error``."""
from hermes_cli.config import _env_line_defines_key, _quote_env_value, _read_env_lines, _write_env_lines
result = RelayMigrationResult(home=home)
env_path = home / ".env"
if not env_path.is_file():
result.skipped_reason = "no .env"
return result
lines = _read_env_lines(env_path)
env: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.removeprefix("export ").partition("=")
env[key.strip()] = value.strip().strip("'\"")
legacy = configured_legacy_relay_env_vars(env)
if not legacy:
result.skipped_reason = "no legacy exporter variables"
return result
if env.get(RELAY_PLUGINS_CONFIG_ENV, "").strip():
result.skipped_reason = f"{RELAY_PLUGINS_CONFIG_ENV} already set"
return result
if not (_truthy(env.get("HERMES_NEMO_RELAY_ATIF_ENABLED")) or _truthy(env.get("HERMES_NEMO_RELAY_ATOF_ENABLED"))):
result.skipped_reason = "no exporter enabled by the legacy variables"
return result
try:
payload = relay_plugin_payload_from_legacy_env(env)
if validate:
result.diagnostics = validate_relay_plugin_payload(payload)
except Exception as exc: # nemo_relay missing or rejecting the payload: leave .env alone
result.validation_error = f"{type(exc).__name__}: {exc}"
return result
toml_path = home / RELAY_PLUGINS_TOML_NAME
header = (
"# NeMo Relay plugin configuration for Hermes (selected via "
f"{RELAY_PLUGINS_CONFIG_ENV} in .env).\n"
"# Generated by `hermes update` from the legacy HERMES_NEMO_RELAY_ATIF_*/ATOF_* variables,\n"
"# which Relay no longer reads. Edit this file to change exporters.\n"
)
toml_path.write_text(header + dumps_toml(payload), encoding="utf-8")
new_lines = _comment_out_legacy_lines(lines, set(LEGACY_RELAY_EXPORT_ENV_VARS))
if not any(_env_line_defines_key(line, RELAY_PLUGINS_CONFIG_ENV) for line in new_lines):
if new_lines and not new_lines[-1].endswith("\n"):
new_lines[-1] += "\n"
new_lines.append(f"{RELAY_PLUGINS_CONFIG_ENV}={_quote_env_value(str(toml_path))}\n")
_write_env_lines(env_path, new_lines, preserve_mode=True)
result.toml_path = toml_path
result.migrated_vars = legacy
return result
def migrate_all_profile_relay_envs(*, validate: bool = True) -> list[RelayMigrationResult]:
"""Default home + every live named profile (multiplex: each profile keeps its own TOML)."""
from hermes_cli.profiles import _get_default_hermes_home, _iter_named_profile_dirs
homes = [_get_default_hermes_home(), *_iter_named_profile_dirs()]
return [migrate_profile_relay_env(home, validate=validate) for home in homes]
def print_relay_migration_report(results: list[RelayMigrationResult]) -> None:
"""Loud, actionable notice for `hermes update` / `hermes relay migrate`."""
migrated = [r for r in results if r.migrated]
failed = [r for r in results if r.validation_error]
if not migrated and not failed:
return
print()
if migrated:
print("\033[1;33m⚠ NeMo Relay exporter configuration migrated\033[0m")
print(" The legacy HERMES_NEMO_RELAY_ATIF_*/ATOF_* variables stopped producing traces after the")
print(" Relay cutover. Each profile below now has a generated relay-plugins.toml selected by")
print(f" {RELAY_PLUGINS_CONFIG_ENV} in its .env (legacy lines commented out, not deleted):")
for r in migrated:
extra = f" ({len(r.diagnostics)} Relay diagnostic(s))" if r.diagnostics else ""
label = r.home.name if r.home.parent.name == "profiles" else "default"
print(f" • {label}: {r.toml_path}{extra}")
print(" Restart the gateway to resume exports. Review the file and adjust paths if needed.")
for r in failed:
print(f" ✗ {r.home}: could not migrate Relay exporter vars — {r.validation_error}")
print(f" Write {r.home / RELAY_PLUGINS_TOML_NAME} by hand and set {RELAY_PLUGINS_CONFIG_ENV}.")
def run_relay_migration_after_update() -> None:
"""`hermes update` hook: migrate every profile home, print the notice. Best-effort by contract."""
print_relay_migration_report(migrate_all_profile_relay_envs())
RELAY_MIGRATE_COMMAND = "hermes migrate relay"
def cmd_migrate_relay(args) -> None:
"""``hermes migrate relay [--all-profiles] [--no-validate]``."""
from hermes_constants import get_hermes_home
validate = not getattr(args, "no_validate", False)
if getattr(args, "all_profiles", False):
results = migrate_all_profile_relay_envs(validate=validate)
else:
results = [migrate_profile_relay_env(get_hermes_home(), validate=validate)]
print_relay_migration_report(results)
for r in results:
if not r.migrated and not r.validation_error:
print(f" {r.home}: nothing to migrate ({r.skipped_reason}).")