Fix dashboard gateway profile scoping
This commit is contained in:
parent
f80381c456
commit
674e8b098a
@ -555,6 +555,41 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
||||
return _read_json_file(_get_runtime_status_path())
|
||||
|
||||
|
||||
def get_runtime_status_running_pid(
|
||||
runtime: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[int]:
|
||||
"""Return a live gateway PID from the runtime status record, if valid.
|
||||
|
||||
``get_running_pid()`` is the primary liveness source because it verifies the
|
||||
runtime lock and PID file. Launch-service managers can still leave us with
|
||||
a live process and a fresh ``gateway_state.json`` but no ``gateway.pid``; use
|
||||
this as a conservative fallback by checking both the persisted state and the
|
||||
OS process identity.
|
||||
"""
|
||||
payload = runtime if runtime is not None else read_runtime_status()
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
|
||||
return None
|
||||
|
||||
pid = _pid_from_record(payload)
|
||||
if pid is None or not _pid_exists(pid):
|
||||
return None
|
||||
|
||||
recorded_start = payload.get("start_time")
|
||||
current_start = _get_process_start_time(pid)
|
||||
if (
|
||||
recorded_start is not None
|
||||
and current_start is not None
|
||||
and current_start != recorded_start
|
||||
):
|
||||
return None
|
||||
|
||||
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(payload):
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
def remove_pid_file() -> None:
|
||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||
|
||||
|
||||
@ -62,7 +62,11 @@ from hermes_cli.config import (
|
||||
recommended_update_command_for_method,
|
||||
redact_key,
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
from gateway.status import (
|
||||
get_running_pid,
|
||||
get_runtime_status_running_pid,
|
||||
read_runtime_status,
|
||||
)
|
||||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
|
||||
|
||||
class TelegramOnboardingApply(BaseModel):
|
||||
allowed_user_ids: List[str]
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
@ -1609,9 +1614,18 @@ async def fs_default_cwd():
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
async def get_status(profile: Optional[str] = None):
|
||||
status_scope = None
|
||||
requested_profile = (profile or "").strip()
|
||||
# Plain /api/status stays the machine-level public liveness probe. The
|
||||
# dashboard adds ?profile= when its management switcher targets another
|
||||
# profile, so its gateway badge reflects the selected profile.
|
||||
if requested_profile and requested_profile.lower() != "current":
|
||||
status_scope = _profile_scope(requested_profile)
|
||||
status_scope.__enter__()
|
||||
|
||||
try:
|
||||
current_ver, latest_ver = check_config_version()
|
||||
# --- Gateway liveness detection ---
|
||||
# Try local PID check first (same-host). If that fails and a remote
|
||||
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
|
||||
@ -1651,6 +1665,10 @@ async def get_status():
|
||||
runtime = read_runtime_status()
|
||||
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
|
||||
runtime = remote_health_body
|
||||
runtime_pid = get_runtime_status_running_pid(runtime)
|
||||
if not gateway_running and runtime_pid is not None:
|
||||
gateway_running = True
|
||||
gateway_pid = runtime_pid
|
||||
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
@ -1748,6 +1766,9 @@ async def get_status():
|
||||
})
|
||||
|
||||
return status
|
||||
finally:
|
||||
if status_scope is not None:
|
||||
status_scope.__exit__(*sys.exc_info())
|
||||
|
||||
|
||||
_WINDOWS_11_MIN_BUILD = 22000
|
||||
@ -2095,6 +2116,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
# report liveness and exit code without shelling out to ``ps``.
|
||||
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
|
||||
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
|
||||
|
||||
# ``name`` → completed synthetic action result for actions the server handled
|
||||
# without spawning a subprocess (for example, unsupported Docker updates).
|
||||
@ -2114,6 +2136,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
|
||||
if not message.endswith("\n"):
|
||||
log_file.write(b"\n")
|
||||
_ACTION_PROCS.pop(name, None)
|
||||
_ACTION_COMMANDS.pop(name, None)
|
||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
|
||||
|
||||
|
||||
@ -2154,6 +2177,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
|
||||
# fd per spawned action.
|
||||
log_file.close()
|
||||
_ACTION_RESULTS.pop(name, None)
|
||||
_ACTION_COMMANDS[name] = tuple(subcommand)
|
||||
_ACTION_PROCS[name] = proc
|
||||
return proc
|
||||
|
||||
@ -2172,7 +2196,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
|
||||
return lines[-n:] if n > 0 else lines
|
||||
|
||||
|
||||
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
||||
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:
|
||||
return _profile_cli_args(profile) + ["gateway", verb]
|
||||
|
||||
|
||||
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
|
||||
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
|
||||
|
||||
|
||||
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
|
||||
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
|
||||
|
||||
Multiple dashboard paths can request a restart in quick succession
|
||||
@ -2183,16 +2215,20 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
|
||||
|
||||
Returns ``(proc, reused)``.
|
||||
"""
|
||||
subcommand = _gateway_subcommand(profile, "restart")
|
||||
existing = _ACTION_PROCS.get("gateway-restart")
|
||||
if existing is not None and existing.poll() is None:
|
||||
existing_command = _ACTION_COMMANDS.get("gateway-restart")
|
||||
if existing_command is None or existing_command == tuple(subcommand):
|
||||
return existing, True
|
||||
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
|
||||
raise RuntimeError("gateway restart already in progress for another profile")
|
||||
return _spawn_hermes_action(subcommand, "gateway-restart"), False
|
||||
|
||||
|
||||
def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
||||
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Best-effort gateway restart after enabling the webhook platform."""
|
||||
try:
|
||||
proc, reused = _spawn_gateway_restart()
|
||||
proc, reused = _spawn_gateway_restart(profile)
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to auto-restart gateway after enabling webhooks")
|
||||
return {
|
||||
@ -2212,10 +2248,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/api/gateway/restart")
|
||||
async def restart_gateway():
|
||||
async def restart_gateway(profile: Optional[str] = None):
|
||||
"""Kick off a ``hermes gateway restart`` in the background."""
|
||||
try:
|
||||
proc, _reused = _spawn_gateway_restart()
|
||||
proc, _reused = _spawn_gateway_restart(profile)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway restart")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
|
||||
@ -2635,6 +2673,7 @@ async def get_action_status(name: str, lines: int = 200):
|
||||
pass
|
||||
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
|
||||
_ACTION_PROCS.pop(name, None)
|
||||
_ACTION_COMMANDS.pop(name, None)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
@ -4346,13 +4385,16 @@ def _messaging_platform_payload(
|
||||
scoped: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
platform_id = entry["id"]
|
||||
gateway_running = get_running_pid() is not None
|
||||
runtime_platforms = runtime.get("platforms") if runtime else {}
|
||||
runtime_platform = (
|
||||
runtime_platforms.get(platform_id, {})
|
||||
if isinstance(runtime_platforms, dict)
|
||||
else {}
|
||||
)
|
||||
gateway_running = (
|
||||
get_running_pid() is not None
|
||||
or get_runtime_status_running_pid(runtime) is not None
|
||||
)
|
||||
env_vars = []
|
||||
|
||||
for key in entry["env_vars"]:
|
||||
@ -4415,15 +4457,37 @@ def _messaging_platform_payload(
|
||||
state = (
|
||||
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||
)
|
||||
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
|
||||
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
|
||||
if not enabled:
|
||||
state = "disabled"
|
||||
elif not configured:
|
||||
state = "not_configured"
|
||||
elif gateway_running and not state:
|
||||
state = "pending_restart"
|
||||
elif (
|
||||
not gateway_running
|
||||
and not state
|
||||
and runtime_gateway_state == "startup_failed"
|
||||
):
|
||||
state = "startup_failed"
|
||||
elif not gateway_running and not state:
|
||||
state = "gateway_stopped"
|
||||
|
||||
error_code = (
|
||||
runtime_platform.get("error_code")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
)
|
||||
error_message = (
|
||||
runtime_platform.get("error_message")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
)
|
||||
if state == "startup_failed":
|
||||
error_code = error_code or "startup_failed"
|
||||
error_message = error_message or runtime_gateway_error
|
||||
|
||||
return {
|
||||
"id": platform_id,
|
||||
"name": entry["name"],
|
||||
@ -4433,16 +4497,8 @@ def _messaging_platform_payload(
|
||||
"configured": configured,
|
||||
"gateway_running": gateway_running,
|
||||
"state": state,
|
||||
"error_code": (
|
||||
runtime_platform.get("error_code")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"error_message": (
|
||||
runtime_platform.get("error_message")
|
||||
if isinstance(runtime_platform, dict)
|
||||
else None
|
||||
),
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
"updated_at": (
|
||||
runtime_platform.get("updated_at")
|
||||
if isinstance(runtime_platform, dict)
|
||||
@ -4732,7 +4788,7 @@ async def get_telegram_onboarding_status(pairing_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
||||
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Best-effort gateway restart after saving Telegram QR onboarding.
|
||||
|
||||
The QR flow naturally pulls users into Telegram on another device. If the
|
||||
@ -4741,7 +4797,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
||||
restart failures so the UI can fall back to the existing manual banner.
|
||||
"""
|
||||
try:
|
||||
proc, reused = _spawn_gateway_restart()
|
||||
proc, reused = _spawn_gateway_restart(profile)
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
|
||||
return {
|
||||
@ -4762,7 +4818,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
|
||||
|
||||
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
|
||||
async def apply_telegram_onboarding(
|
||||
pairing_id: str, body: TelegramOnboardingApply
|
||||
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
|
||||
):
|
||||
allowed_user_ids = []
|
||||
seen = set()
|
||||
@ -4798,10 +4854,14 @@ async def apply_telegram_onboarding(
|
||||
detail="Telegram setup is not ready yet.",
|
||||
)
|
||||
|
||||
effective_profile = body.profile or profile
|
||||
try:
|
||||
with _profile_scope(effective_profile):
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
|
||||
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
|
||||
_write_platform_enabled("telegram", True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
@ -4814,7 +4874,7 @@ async def apply_telegram_onboarding(
|
||||
with _telegram_onboarding_lock:
|
||||
_telegram_onboarding_pairings.pop(pairing_id, None)
|
||||
|
||||
restart_result = _restart_gateway_after_telegram_onboarding()
|
||||
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@ -4842,6 +4902,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
|
||||
env_on_disk = load_env()
|
||||
runtime = read_runtime_status()
|
||||
return {
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_start_command": _gateway_display_command(profile, "start"),
|
||||
"platforms": [
|
||||
_messaging_platform_payload(
|
||||
entry, env_on_disk, runtime, scoped=scoped_dir is not None
|
||||
@ -7802,9 +7864,11 @@ async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
||||
|
||||
|
||||
@app.post("/api/gateway/start")
|
||||
async def start_gateway():
|
||||
async def start_gateway(profile: Optional[str] = None):
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
|
||||
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway start")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
|
||||
@ -7812,9 +7876,11 @@ async def start_gateway():
|
||||
|
||||
|
||||
@app.post("/api/gateway/stop")
|
||||
async def stop_gateway():
|
||||
async def stop_gateway(profile: Optional[str] = None):
|
||||
try:
|
||||
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
|
||||
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn gateway stop")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
|
||||
@ -8346,7 +8412,7 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
|
||||
profile (no args, legacy behavior).
|
||||
"""
|
||||
requested = (profile or "").strip()
|
||||
if not requested or requested.lower() == "current":
|
||||
if not requested or requested.lower() in {"current", "default"}:
|
||||
return []
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
_resolve_profile_dir(requested)
|
||||
|
||||
@ -20,3 +20,34 @@ def test_runtime_health_lines_include_fatal_platform_and_startup_reason(monkeypa
|
||||
|
||||
assert "⚠ telegram: another poller is active" in lines
|
||||
assert "⚠ Last startup issue: telegram conflict" in lines
|
||||
|
||||
|
||||
def test_runtime_status_running_pid_validates_live_gateway_record(monkeypatch):
|
||||
from gateway import status as status_mod
|
||||
|
||||
runtime = {
|
||||
"pid": 12345,
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
|
||||
"start_time": None,
|
||||
"gateway_state": "running",
|
||||
}
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: pid == 12345)
|
||||
monkeypatch.setattr(status_mod, "_get_process_start_time", lambda pid: None)
|
||||
monkeypatch.setattr(status_mod, "_looks_like_gateway_process", lambda pid: False)
|
||||
|
||||
assert status_mod.get_runtime_status_running_pid(runtime) == 12345
|
||||
|
||||
|
||||
def test_runtime_status_running_pid_rejects_stopped_record(monkeypatch):
|
||||
from gateway import status as status_mod
|
||||
|
||||
runtime = {
|
||||
"pid": 12345,
|
||||
"kind": "hermes-gateway",
|
||||
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
|
||||
"gateway_state": "stopped",
|
||||
}
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: True)
|
||||
|
||||
assert status_mod.get_runtime_status_running_pid(runtime) is None
|
||||
|
||||
@ -91,6 +91,47 @@ class TestProfileScopedMessagingReads:
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_scoped_read_returns_profile_path_command_and_startup_failure(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
worker_home = isolated_profiles["worker_alpha"]
|
||||
(worker_home / ".env").write_text(
|
||||
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
|
||||
)
|
||||
(worker_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"read_runtime_status",
|
||||
lambda: {
|
||||
"gateway_state": "startup_failed",
|
||||
"exit_reason": "all configured messaging platforms failed to connect",
|
||||
"platforms": {},
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(
|
||||
"/api/messaging/platforms", params={"profile": "worker_alpha"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload["env_path"] == str(worker_home / ".env")
|
||||
assert payload["gateway_start_command"] == (
|
||||
"hermes -p worker_alpha gateway start"
|
||||
)
|
||||
telegram = _telegram(payload)
|
||||
assert telegram["state"] == "startup_failed"
|
||||
assert telegram["error_code"] == "startup_failed"
|
||||
assert telegram["error_message"] == (
|
||||
"all configured messaging platforms failed to connect"
|
||||
)
|
||||
|
||||
|
||||
class TestProfileScopedMessagingWrites:
|
||||
def test_scoped_write_lands_in_target_profile_env(
|
||||
|
||||
@ -353,6 +353,167 @@ class TestProfileScopedPostSetup:
|
||||
assert calls == [["tools", "post-setup", "agent_browser"]]
|
||||
|
||||
|
||||
class TestProfileScopedGateway:
|
||||
def test_lifecycle_spawns_with_profile_flag(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 888
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
|
||||
)
|
||||
web_server._ACTION_PROCS.pop("gateway-restart", None)
|
||||
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
|
||||
|
||||
for verb in ("start", "stop", "restart"):
|
||||
resp = client.post(f"/api/gateway/{verb}", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert calls == [
|
||||
(["-p", "worker_beta", "gateway", "start"], "gateway-start"),
|
||||
(["-p", "worker_beta", "gateway", "stop"], "gateway-stop"),
|
||||
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart"),
|
||||
]
|
||||
|
||||
def test_status_reads_requested_profile_home(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
seen_homes = []
|
||||
|
||||
def fake_get_running_pid():
|
||||
seen_homes.append(str(get_hermes_home()))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
monkeypatch.setattr(web_server, "get_running_pid", fake_get_running_pid)
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"read_runtime_status",
|
||||
lambda: {"gateway_state": "startup_failed", "platforms": {}},
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert seen_homes[0] == str(isolated_profiles["worker_beta"])
|
||||
assert resp.json()["hermes_home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_status_uses_runtime_pid_when_profile_pid_file_is_missing(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
worker_home = isolated_profiles["worker_beta"]
|
||||
(worker_home / ".env").write_text(
|
||||
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
|
||||
)
|
||||
(worker_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime = {
|
||||
"pid": 4242,
|
||||
"gateway_state": "running",
|
||||
"platforms": {"telegram": {"state": "connected"}},
|
||||
"exit_reason": None,
|
||||
"updated_at": "2026-06-17T00:00:00+00:00",
|
||||
}
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
|
||||
monkeypatch.setattr(web_server, "read_runtime_status", lambda: runtime)
|
||||
monkeypatch.setattr(
|
||||
web_server, "get_runtime_status_running_pid", lambda payload: 4242
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
from gateway.config import Platform
|
||||
|
||||
class _FakeGatewayConfig:
|
||||
def get_connected_platforms(self):
|
||||
return [Platform.TELEGRAM]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.config.load_gateway_config", lambda: _FakeGatewayConfig()
|
||||
)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gateway_running"] is True
|
||||
assert data["gateway_pid"] == 4242
|
||||
assert data["gateway_state"] == "running"
|
||||
assert data["gateway_platforms"] == {"telegram": {"state": "connected"}}
|
||||
|
||||
|
||||
class TestProfileScopedTelegramOnboarding:
|
||||
def test_apply_writes_target_profile_and_restarts_target(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import time
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
with web_server._telegram_onboarding_lock:
|
||||
web_server._telegram_onboarding_pairings.clear()
|
||||
web_server._telegram_onboarding_pairings["pair-worker"] = (
|
||||
web_server._TelegramOnboardingPairing(
|
||||
poll_token="poll-secret",
|
||||
expires_at="2027-05-18T00:00:00.000Z",
|
||||
expires_at_ts=time.time() + 600,
|
||||
bot_token="123456:SECRET",
|
||||
bot_username="worker_bot",
|
||||
owner_user_id="123456789",
|
||||
)
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 889
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
|
||||
)
|
||||
web_server._ACTION_PROCS.pop("gateway-restart", None)
|
||||
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
|
||||
|
||||
resp = client.post(
|
||||
"/api/messaging/telegram/onboarding/pair-worker/apply",
|
||||
params={"profile": "worker_beta"},
|
||||
json={"allowed_user_ids": ["123456789"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["restart_started"] is True
|
||||
assert calls == [
|
||||
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart")
|
||||
]
|
||||
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "TELEGRAM_BOT_TOKEN=123456:SECRET" in worker_env
|
||||
assert "TELEGRAM_ALLOWED_USERS=123456789" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "TELEGRAM_BOT_TOKEN" not in default_env_path.read_text()
|
||||
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
default_cfg = _cfg(isolated_profiles["default"])
|
||||
assert worker_cfg["platforms"]["telegram"]["enabled"] is True
|
||||
assert default_cfg.get("platforms", {}).get("telegram", {}).get("enabled") is not True
|
||||
|
||||
|
||||
class TestProfileScopedChatPty:
|
||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
@ -60,10 +60,11 @@ export function getManagementProfile(): string {
|
||||
|
||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
|
||||
// pairing, telegram onboarding, cron (which has its own per-job profile
|
||||
// params), profiles themselves — is machine-global or self-scoped and must
|
||||
// NOT be rewritten.
|
||||
// pairing, cron (which has its own per-job profile params), profiles
|
||||
// themselves — is machine-global or self-scoped and must NOT be rewritten.
|
||||
const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/status",
|
||||
"/api/gateway",
|
||||
"/api/analytics",
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
@ -71,6 +72,7 @@ const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/env",
|
||||
"/api/mcp",
|
||||
"/api/messaging/platforms",
|
||||
"/api/messaging/telegram/onboarding",
|
||||
"/api/model/info",
|
||||
"/api/model/set",
|
||||
"/api/model/auxiliary",
|
||||
@ -771,7 +773,7 @@ export const api = {
|
||||
|
||||
// Messaging platforms (gateway channels)
|
||||
getMessagingPlatforms: () =>
|
||||
fetchJSON<{ platforms: MessagingPlatform[] }>("/api/messaging/platforms"),
|
||||
fetchJSON<MessagingPlatformsResponse>("/api/messaging/platforms"),
|
||||
updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) =>
|
||||
fetchJSON<{ ok: boolean; platform: string }>(
|
||||
`/api/messaging/platforms/${encodeURIComponent(id)}`,
|
||||
@ -801,7 +803,7 @@ export const api = {
|
||||
),
|
||||
applyTelegramOnboarding: (
|
||||
pairingId: string,
|
||||
body: { allowed_user_ids: string[] },
|
||||
body: { allowed_user_ids: string[]; profile?: string },
|
||||
) =>
|
||||
fetchJSON<TelegramOnboardingApplyResponse>(
|
||||
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`,
|
||||
@ -1339,7 +1341,7 @@ export interface MessagingPlatform {
|
||||
gateway_running: boolean;
|
||||
/**
|
||||
* "connected" | "disabled" | "not_configured" | "pending_restart" |
|
||||
* "gateway_stopped" | "disconnected" | "fatal" | string
|
||||
* "gateway_stopped" | "startup_failed" | "disconnected" | "fatal" | string
|
||||
*/
|
||||
state: string;
|
||||
error_code: string | null;
|
||||
@ -1349,6 +1351,12 @@ export interface MessagingPlatform {
|
||||
env_vars: MessagingPlatformEnvVar[];
|
||||
}
|
||||
|
||||
export interface MessagingPlatformsResponse {
|
||||
env_path: string;
|
||||
gateway_start_command: string;
|
||||
platforms: MessagingPlatform[];
|
||||
}
|
||||
|
||||
export interface MessagingPlatformUpdate {
|
||||
enabled?: boolean;
|
||||
env?: Record<string, string>;
|
||||
|
||||
@ -43,6 +43,7 @@ const STATE_BADGE: Record<
|
||||
connected: { tone: "success", label: "Connected" },
|
||||
pending_restart: { tone: "warning", label: "Restart to apply" },
|
||||
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
|
||||
startup_failed: { tone: "destructive", label: "Start failed" },
|
||||
disconnected: { tone: "warning", label: "Disconnected" },
|
||||
not_configured: { tone: "outline", label: "Not configured" },
|
||||
disabled: { tone: "secondary", label: "Disabled" },
|
||||
@ -71,6 +72,10 @@ function isTerminalTelegramOnboardingError(error: unknown): boolean {
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
|
||||
const [envPath, setEnvPath] = useState("~/.hermes/.env");
|
||||
const [gatewayStartCommand, setGatewayStartCommand] = useState(
|
||||
"hermes gateway start",
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
@ -93,7 +98,11 @@ export default function ChannelsPage() {
|
||||
const load = useCallback(() => {
|
||||
return api
|
||||
.getMessagingPlatforms()
|
||||
.then((res) => setPlatforms(res.platforms))
|
||||
.then((res) => {
|
||||
setPlatforms(res.platforms);
|
||||
setEnvPath(res.env_path || "~/.hermes/.env");
|
||||
setGatewayStartCommand(res.gateway_start_command || "hermes gateway start");
|
||||
})
|
||||
.catch((e) => showToast(`Error: ${e}`, "error"));
|
||||
}, [showToast]);
|
||||
|
||||
@ -253,7 +262,7 @@ export default function ChannelsPage() {
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
The gateway is not running. Configure channels here, then start the
|
||||
gateway with <code className="font-courier">hermes gateway start</code>{" "}
|
||||
gateway with <code className="font-courier">{gatewayStartCommand}</code>{" "}
|
||||
(or the Restart button above).
|
||||
</span>
|
||||
</CardContent>
|
||||
@ -262,7 +271,7 @@ export default function ChannelsPage() {
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{configured} of {platforms.length} channels configured. Credentials are
|
||||
written to <code className="font-courier">~/.hermes/.env</code>; the
|
||||
written to <code className="font-courier">{envPath}</code>; the
|
||||
gateway connects each enabled channel on its next restart.
|
||||
</p>
|
||||
|
||||
@ -369,7 +378,7 @@ export default function ChannelsPage() {
|
||||
const StateIcon =
|
||||
platform.state === "connected"
|
||||
? CheckCircle2
|
||||
: platform.state === "fatal"
|
||||
: platform.state === "fatal" || platform.state === "startup_failed"
|
||||
? AlertTriangle
|
||||
: Radio;
|
||||
return (
|
||||
@ -382,7 +391,8 @@ export default function ChannelsPage() {
|
||||
"h-5 w-5 shrink-0 mt-0.5",
|
||||
platform.state === "connected"
|
||||
? "text-success"
|
||||
: platform.state === "fatal"
|
||||
: platform.state === "fatal" ||
|
||||
platform.state === "startup_failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user