diff --git a/hermes_cli/web_deps.py b/hermes_cli/web_deps.py index 68626ba2cf..5c2b99ad56 100644 --- a/hermes_cli/web_deps.py +++ b/hermes_cli/web_deps.py @@ -1,30 +1,35 @@ """Late-binding dependency seam for extracted dashboard routers. -``web_server`` owns all dashboard state/helpers; routers under ``web_routers/`` cannot -import it at import time (web_server imports them to mount them — a cycle) and must not -copy its state (tests ``monkeypatch.setattr(web_server, ...)`` and expect that to win). -``late(name)`` / ``LateState(name)`` resolve ``web_server.`` *at call time*. +``web_server`` owns the dashboard app and its process state; routers under +``web_routers/`` cannot import it at import time (web_server imports them to mount them — +a cycle) and must not copy its state (tests ``monkeypatch.setattr(web_server, ...)`` and +expect that to win). ``late(name)`` / ``LateState(name)`` resolve ``.`` *at +call time*; ``module`` defaults to ``web_server`` and may name a ``web_server_`` +module whose helper tests monkeypatch there. """ from __future__ import annotations +import importlib import sys from typing import Any +WEB_SERVER = "hermes_cli.web_server" -def _server(): - """Return the live ``hermes_cli.web_server`` module (imported on demand).""" - mod = sys.modules.get("hermes_cli.web_server") + +def _server(module: str = WEB_SERVER): + """Return the live ``module`` (imported on demand).""" + mod = sys.modules.get(module) if mod is None: # pragma: no cover - routers are only mounted by web_server - import hermes_cli.web_server as mod # type: ignore[no-redef] + mod = importlib.import_module(module) return mod -def late(name: str): - """Late-binding proxy for a callable defined on ``web_server``.""" +def late(name: str, module: str = WEB_SERVER): + """Late-binding proxy for a callable defined on ``module``.""" def _proxy(*args: Any, **kwargs: Any): - return getattr(_server(), name)(*args, **kwargs) + return getattr(_server(module), name)(*args, **kwargs) _proxy.__name__ = name _proxy.__qualname__ = name @@ -39,13 +44,15 @@ class LateState: defined *after* the router's ``include_router`` point, so a late import would miss it. """ - __slots__ = ("_name",) + __slots__ = ("_name", "_module") - def __init__(self, name: str) -> None: + def __init__(self, name: str, module: str = WEB_SERVER) -> None: object.__setattr__(self, "_name", name) + object.__setattr__(self, "_module", module) def _target(self) -> Any: - return getattr(_server(), object.__getattribute__(self, "_name")) + return getattr(_server(object.__getattribute__(self, "_module")), + object.__getattribute__(self, "_name")) def __getattr__(self, attr: str) -> Any: return getattr(self._target(), attr) diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index 5a40ebf168..00bcb966b0 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -1,8 +1,4 @@ -"""Pydantic request/response models for the Hermes dashboard web server. - -``web_server`` re-exports every name here, so existing imports like -``from hermes_cli.web_server import ConfigUpdate`` keep working. -""" +"""Pydantic request/response models for the Hermes dashboard web server.""" from __future__ import annotations diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f25e206e91..422c67b8b0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -15,7 +15,6 @@ import logging import os import re import secrets -import shutil # noqa: F401 — tests monkeypatch web_server.shutil.which import subprocess import sys import sysconfig @@ -34,29 +33,12 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from hermes_cli import __version__ -from hermes_cli.config import ( # noqa: F401 — late-bound by extracted routers/modules; tests monkeypatch web_server. - cfg_get, - check_config_version, - detect_install_method, - get_hermes_home, - load_config, - load_env, - remove_env_value, - save_config, - save_env_value, -) -from gateway.status import ( # noqa: F401 — late-bound by web_routers/status + tests monkeypatch web_server. - get_running_pid, - get_running_pid_cached, - get_runtime_status_running_pid, - read_runtime_status, -) +from hermes_cli.config import load_config try: from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse - from starlette.concurrency import run_in_threadpool # noqa: F401 — late-bound by web_server_cron/routers; tests patch web_server.run_in_threadpool except ImportError: # First try lazy-installing the dashboard extras. Only the user actually # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps @@ -67,7 +49,6 @@ except ImportError: from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse - from starlette.concurrency import run_in_threadpool # noqa: F401 except Exception: raise SystemExit( "Web UI requires fastapi and uvicorn.\n" @@ -78,19 +59,15 @@ WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.enviro _log = logging.getLogger(__name__) -from hermes_cli.web_server_lifecycle import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. +from hermes_cli.web_server_lifecycle import ( # noqa: E402 PORT_IN_USE_EXIT_CODE, _dashboard_forwarded_allow_ips, _eager_reconcile_own_session_db, - _is_addr_in_use_error, - _is_serve_orphaned, _maybe_open_browser, _port_bind_conflict, _read_bound_port, _report_port_in_use, - _resolve_restart_drain_timeout, _start_parent_death_watchdog, - _valid_parent_start_marker, _warm_gateway_module, _write_dashboard_ready_file, _write_machine_sentinel_line, @@ -779,123 +756,17 @@ async def _dashboard_selftest_loop() -> None: await _dashboard_selftest_once() -# Helpers extracted into web_server_ modules, re-exported so -# ``web_server.`` stays the late-binding seam (web_deps.late) routers use -# and tests monkeypatch. Every name here has a live web_server. reference. -from hermes_cli.web_server_config import ( # noqa: E402,F401 - CONFIG_SCHEMA, _AUX_TASK_SLOTS, _apply_main_model_assignment, _apply_model_assignment_sync, - _dashboard_code_skew_guard, _denormalize_config_from_web, _memory_provider_options, - _normalize_config_for_web, _normalize_main_model_assignment, - _schema_with_dynamic_provider_options, _timezone_options, -) -from hermes_cli.web_models import ( # noqa: E402,F401 - ConfigUpdate, WhatsAppOnboardingStart, WhatsAppOnboardingApply, MoaModelSlot, MoaPresetPayload, - MoaConfigPayload, BulkDeleteSessions, CronJobCreate, CronJobUpdate, - AutomationBlueprintInstantiate, MCPServerCreate, -) -from hermes_cli.web_server_gateway import ( # noqa: E402,F401 - _ACTION_COMMANDS, _ACTION_IDS, _ACTION_LOG_DIR, _ACTION_LOG_FILES, _ACTION_PROCS, - _ACTION_RESULTS, _TOPOLOGY_CACHE, _TOPOLOGY_CACHE_TTL, _collect_profile_gateway_topology, - _collect_profile_gateway_topology_cached, _dashboard_spawn_executable, _display_system_platform, - _gateway_subcommand, _load_configured_gateway_platforms, _probe_gateway_health, - _profile_gateway_writer_identity, _profile_platform_ports, _restart_gateway_after, - _spawn_hermes_action, _split_text_for_speak_stream, _strip_session_list_rows, - _terminate_desktop_managed_gateway, -) -from hermes_cli.web_server_files import ( # noqa: E402,F401 - _dashboard_local_update_managed_externally, _fs_path, _managed_file_entry, - _managed_response_meta, _path_is_under, _resolve_managed_path, -) -from hermes_cli.web_server_memory import ( # noqa: E402,F401 - _coerce_bool, _dependency_importable, _discover_memory_provider_statuses, _field_default, - _field_is_set, _field_value, _field_visible, _load_memory_provider, _memory_provider_manifest, - _memory_provider_setup_info, _memory_provider_setup_manifest, _normalize_memory_provider_name, - _normalize_memory_provider_schema, _read_memory_provider_existing_values, - _require_memory_provider_ready, _run_setup_command, -) -from hermes_cli.web_server_profiles import ( # noqa: E402,F401 - _profile_cli_args, _hub_action_name, _installed_hub_identifiers, _SKILLS_PROFILE_LOCK, - _TERMINAL_BACKENDS, _approval_mode_of, _aux_task_summary, _aux_usage_rows, - _broadcast_gateway_session_info, _config_profile_scope, _fallback_profile_dicts, - _is_other_profile, _merge_aux_into_by_model, _parse_model_ids, _plugin_terminal_backend_rows, - _profile_scope, _resolve_profile_dir, _write_profile_mcp_servers, -) -from hermes_cli.web_server_messaging import ( # noqa: E402,F401 - _MESSAGING_KEYS_PAGE_KEYS, _TelegramOnboardingPairing, _WhatsAppOnboardingSession, - _build_catalog_entry, _channel_managed_env_keys, _messaging_platform_catalog, - _restart_gateway_after_whatsapp_onboarding, _telegram_onboarding_error_message, - _telegram_onboarding_lock, _telegram_onboarding_pairings, _telegram_onboarding_request_sync, - _whatsapp_onboarding_payload, _whatsapp_onboarding_sessions, _whatsapp_session_path, - _write_platform_enabled, -) -from hermes_cli.web_server_oauth import ( # noqa: E402,F401 - _OAUTH_PROVIDER_CATALOG, _external_process_cli_command, _minimax_poller, _nous_poller, - _oauth_profile_name, _oauth_sessions, _oauth_sessions_lock, _truncate_token, _xai_device_poller, -) -from hermes_cli.web_server_sessions import ( # noqa: E402,F401 - _auto_archive_ticker_loop, _last_auto_archive_check, _maybe_auto_archive_for_profile, - _open_session_db_at_path, _open_session_db_for_profile, _session_db_heal_exhausted, - _session_db_heal_warned, _session_db_read_probe_statements, _session_latest_descendant, -) -from hermes_cli.web_server_cron import ( # noqa: E402,F401 - _call_cron_for_profile, _create_cron_job_sync, _cron_default_profile, _cron_optional_text, - _cron_profile_dicts, _cron_profile_home, _cron_string_list, _find_cron_job_profile, - _fire_cron_job_for_profile, _forward_cron_fire_to_gateway, _gateway_fire_endpoint, - _gateway_intentionally_stopped, _mutate_cron_for_profile, _normalize_dashboard_cron_script, - _notify_cron_provider_for_profile, _raise_if_cron_registration_error, _run_cron_dashboard_io, - _validate_dashboard_cron_context_from, _validate_dashboard_cron_effective_job, -) -from hermes_cli.web_server_mcp import ( # noqa: E402,F401 - _mcp_oauth_flows, _mcp_server_summary, _normalize_mcp_server_create, _run_dashboard_mcp_oauth, -) -from hermes_cli.web_server_chat import ( # noqa: E402,F401 - PTY_REGISTRY, PtyBridge, PtyUnavailableError, _GATEWAY_WS_PROTOCOL, - _GATEWAY_WS_TICKET_PROTOCOL_PREFIX, _LOOPBACK_HOSTS, _PTY_BRIDGE_AVAILABLE, _RESIZE_RE, - _active_session_file_for_channel, _build_gateway_ws_url, _build_sidecar_url, - _get_console_executor, _legacy_pump, _resolve_chat_argv, _resolve_chat_argv_async, - _resolve_client_ws_host, _ws_auth_ok, _ws_auth_reason, _ws_client_is_allowed, _ws_client_reason, - _ws_host_origin_is_allowed, _ws_host_origin_reason, _ws_request_is_allowed, -) -from hermes_cli.web_server_dashboard import ( # noqa: E402,F401 - _BUILTIN_DASHBOARD_THEMES, _discover_dashboard_plugins, _discover_user_themes, - _invalidate_plugins_hub_cache, _merged_plugins_hub, _mount_plugin_api_routes, - _normalise_theme_definition, _render_active_theme_bootstrap_css, _safe_plugin_api_relpath, - _schedule_check_fn_probe, mount_spa, -) -# Legacy re-exports of route handlers; tests call these via web_server.. -from hermes_cli.web_routers.files import upload_managed_file_stream # noqa: E402,F401 -from hermes_cli.web_routers.status import get_status, run_dump # noqa: E402,F401 -from hermes_cli.web_routers.sessions import search_sessions # noqa: E402,F401 -from hermes_cli.web_routers.models import ( # noqa: E402,F401 - get_model_options, get_recommended_default_model, set_moa_models, +# Action registries/spawner are owned by web_server_gateway; routers and tests reach them +# there, so this module reads them through the module too (one patch seam). +from hermes_cli import web_server_gateway as _gateway_mod # noqa: E402 +from hermes_cli.web_server_gateway import _ACTION_LOG_FILES, _terminate_desktop_managed_gateway # noqa: E402 +from hermes_cli.web_server_sessions import _auto_archive_ticker_loop # noqa: E402 +from hermes_cli.web_server_chat import PTY_REGISTRY # noqa: E402 +from hermes_cli.web_server_dashboard import ( # noqa: E402 + _discover_dashboard_plugins, _mount_plugin_api_routes, mount_spa, ) -from hermes_cli.web_routers.messaging import ( # noqa: E402,F401 - apply_whatsapp_onboarding, start_whatsapp_onboarding, -) -from hermes_cli.web_routers.oauth import ( # noqa: E402,F401 - _codex_full_login_worker, _new_oauth_session, _resolve_provider_status, -) -from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 - bulk_delete_sessions_endpoint, count_empty_sessions_endpoint, delete_empty_sessions_endpoint, - get_session_latest_descendant, get_session_messages, delete_session_endpoint, - export_session_endpoint, prune_sessions_endpoint, -) -from hermes_cli.web_routers.cron import ( # noqa: E402,F401 - list_cron_jobs, create_cron_job, update_cron_job, pause_cron_job, resume_cron_job, - trigger_cron_job, delete_cron_job, instantiate_blueprint, _normalize_dashboard_cron_updates, -) -from hermes_cli.web_routers.ops import ( # noqa: E402,F401 - list_credential_pool, run_doctor, run_import, -) -from hermes_cli.web_routers.analytics import ( # noqa: E402,F401 - get_models_analytics, get_usage_analytics, -) -from hermes_cli.web_routers.chat_ws import ( # noqa: E402,F401 - _broadcast_event, _get_event_state, pty_ws, -) -from hermes_cli.web_routers.dashboard_ui import post_agent_plugin_install # noqa: E402,F401 _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL") @@ -978,10 +849,10 @@ def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Po global _LAST_GATEWAY_RESTART - subcommand = _gateway_subcommand(profile, "restart") - existing = _ACTION_PROCS.get("gateway-restart") + subcommand = _gateway_mod._gateway_subcommand(profile, "restart") + existing = _gateway_mod._ACTION_PROCS.get("gateway-restart") if existing is not None and existing.poll() is None: - existing_command = _ACTION_COMMANDS.get("gateway-restart") + existing_command = _gateway_mod._ACTION_COMMANDS.get("gateway-restart") if existing_command is None or existing_command == tuple(subcommand): return existing, True raise RuntimeError("gateway restart already in progress for another profile") @@ -1000,7 +871,7 @@ def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Po ) return recent_proc, True - proc = _spawn_hermes_action(subcommand, "gateway-restart") + proc = _gateway_mod._spawn_hermes_action(subcommand, "gateway-restart") _LAST_GATEWAY_RESTART = (time.monotonic(), proc, tuple(subcommand)) return proc, False diff --git a/hermes_cli/web_server_chat.py b/hermes_cli/web_server_chat.py index 438fec2c3c..d92d6e7b79 100644 --- a/hermes_cli/web_server_chat.py +++ b/hermes_cli/web_server_chat.py @@ -1,9 +1,5 @@ """Chat/terminal WebSocket plumbing: PTY bridge selection and registry, WS client/origin/auth gates, chat argv resolution, gateway/sidecar URL building. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. """ import logging @@ -184,7 +180,6 @@ def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]: def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: """True when the upgrade passes the dashboard Host/Origin guard.""" - from hermes_cli.web_server import _ws_host_origin_reason return _ws_host_origin_reason(ws) is None @@ -286,7 +281,6 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]: def _ws_auth_ok(ws: "WebSocket") -> bool: """True when the WS-upgrade credential is accepted. See _ws_auth_reason.""" - from hermes_cli.web_server import _ws_auth_reason return _ws_auth_reason(ws)[0] is None @@ -303,10 +297,10 @@ def _resolve_chat_argv( ``profile`` scopes the ENTIRE chat by pointing ``HERMES_HOME`` at the profile dir, the same propagation ``hermes -p `` performs. """ - from hermes_cli.web_server import ( - _config_profile_scope, _open_session_db_for_profile, _resolve_profile_dir, - _session_latest_descendant) - from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv + from hermes_cli.web_server_profiles import _config_profile_scope, _resolve_profile_dir + from hermes_cli.web_server_sessions import _open_session_db_for_profile, _session_latest_descendant + from hermes_cli.main import PROJECT_ROOT + from hermes_cli.main_tui_launch import _apply_tui_python_env, _make_tui_argv profile_dir: Optional[Path] = None requested = (profile or "").strip() @@ -428,7 +422,7 @@ async def _resolve_chat_argv_async( active_session_file: Optional[str] = None) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve chat argv off the event loop (it may run ``npm run build``); the async lock keeps one-build-at-a-time without parking worker threads.""" - from hermes_cli.web_server import _get_chat_argv_lock, _resolve_chat_argv, app + from hermes_cli.web_server import _get_chat_argv_lock, app kwargs = {"resume": resume, "sidecar_url": sidecar_url, "profile": profile} if active_session_file is not None: kwargs["active_session_file"] = active_session_file diff --git a/hermes_cli/web_server_config.py b/hermes_cli/web_server_config.py index 6da1a7536e..5810b0e467 100644 --- a/hermes_cli/web_server_config.py +++ b/hermes_cli/web_server_config.py @@ -1,8 +1,4 @@ """Dashboard config schema and model-assignment logic: CONFIG_SCHEMA construction, dynamic provider options, web<->config normalisation, main/aux model assignment. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging diff --git a/hermes_cli/web_server_cron.py b/hermes_cli/web_server_cron.py index 2f8fc25867..907753f863 100644 --- a/hermes_cli/web_server_cron.py +++ b/hermes_cli/web_server_cron.py @@ -1,9 +1,5 @@ """Dashboard cron helpers: per-profile scheduler I/O, job validation/normalisation, cron fire and gateway forwarding. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there, so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import contextlib @@ -72,7 +68,6 @@ def _validate_dashboard_cron_effective_job(job: Dict[str, Any]) -> None: def _validate_dashboard_cron_context_from(refs: Optional[List[str]], profile_name: str) -> None: - from hermes_cli.web_server import _call_cron_for_profile for ref in refs or (): # "self" (the continuity toggle) resolves to the job's own id at run time — it can't be # validated against the store (create precedes the job's existence). @@ -87,7 +82,7 @@ def _validate_dashboard_cron_context_from(refs: Optional[List[str]], profile_nam def _cron_profile_dicts() -> List[Dict[str, Any]]: """Minimal profile records (callers only consume ``name``); avoids ``list_profiles()``, whose config parsing, gateway probes and skill counts are GIL pressure on large pools.""" - from hermes_cli.web_server import _fallback_profile_dicts + from hermes_cli.web_server_profiles import _fallback_profile_dicts from hermes_cli import profiles as profiles_mod try: return [ @@ -116,7 +111,6 @@ def _cron_default_profile() -> str: def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]: """Resolve a profile query value to (profile_name, HERMES_HOME).""" - from hermes_cli.web_server import _cron_default_profile from hermes_cli import profiles as profiles_mod raw = (profile or _cron_default_profile()).strip() or "default" try: @@ -157,7 +151,6 @@ def _cron_store_scope(home: Path): def _call_cron_for_profile(target_profile: Optional[str], func_name: str, *args, **kwargs): """Run a cron.jobs helper against the selected profile's cron directory.""" - from hermes_cli.web_server import _cron_profile_home profile_name, home = _cron_profile_home(target_profile) with _cron_store_scope(home) as cron_jobs: if func_name == "create_job": @@ -182,7 +175,6 @@ def _notify_cron_provider_for_profile(target_profile: Optional[str]) -> None: affected profile re-arms on its next fire/start (idempotent via dedup_key). The built-in provider re-reads jobs.json each tick and stays a no-op here. """ - from hermes_cli.web_server import _cron_profile_dicts, _cron_profile_home try: _profile_name, home = _cron_profile_home(target_profile) from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler @@ -205,7 +197,6 @@ def _notify_cron_provider_for_profile(target_profile: Optional[str]) -> None: def _mutate_cron_for_profile(target_profile: Optional[str], func_name: str, *args, **kwargs): """Apply a cron store mutation and reconcile its scheduler provider.""" - from hermes_cli.web_server import _call_cron_for_profile, _notify_cron_provider_for_profile result = _call_cron_for_profile(target_profile, func_name, *args, **kwargs) if result: _notify_cron_provider_for_profile(target_profile) @@ -213,7 +204,6 @@ def _mutate_cron_for_profile(target_profile: Optional[str], func_name: str, *arg def _find_cron_job_profile(job_id: str) -> Optional[str]: - from hermes_cli.web_server import _call_cron_for_profile, _cron_profile_dicts for profile in _cron_profile_dicts(): name = str(profile.get("name") or "") if not name: @@ -226,7 +216,7 @@ def _find_cron_job_profile(job_id: str) -> Optional[str]: async def _run_cron_dashboard_io(func, *args, **kwargs): """Run cron dashboard profile/job I/O outside the FastAPI event loop.""" - from hermes_cli.web_server import run_in_threadpool + from starlette.concurrency import run_in_threadpool if inspect.iscoroutinefunction(func): raise TypeError("_run_cron_dashboard_io only accepts sync callables") result = await run_in_threadpool(func, *args, **kwargs) @@ -244,7 +234,6 @@ def _raise_if_cron_registration_error(e: Exception) -> None: def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): - from hermes_cli.web_server import _cron_profile_home try: profile_name, profile_home = _cron_profile_home(profile) script = _normalize_dashboard_cron_script(body.script, profile_home) @@ -286,7 +275,6 @@ def _fire_cron_job_for_profile(profile: str, job_id: str, *, force: bool = False cannot serve relay-fronted platforms or E2EE rooms). Retained for the dashboard trigger path and external callers on the web_deps late-binding seam; do not add new uses. """ - from hermes_cli.web_server import _cron_profile_home _profile_name, home = _cron_profile_home(profile) from cron.scheduler_provider import provider_supports_force_fire, resolve_cron_scheduler with _cron_store_scope(home): @@ -332,7 +320,7 @@ def _gateway_fire_endpoint(profile: str, home: Path) -> str: mirrors under ``/p//…``, so a non-default profile's port must be read from the default home (a secondary's own API_SERVER_PORT is a port nothing listens on). """ - from hermes_cli.web_server import _cron_default_profile, load_config + from hermes_cli.config import load_config import os as _os multiplex = False try: @@ -396,7 +384,6 @@ async def _forward_cron_fire_to_gateway( de-dupes a double fire) — unless :func:`_gateway_intentionally_stopped`, in which case it drops the fire with 200: retrying into an operator-stopped gateway can never succeed. """ - from hermes_cli.web_server import _cron_profile_home _profile_name, home = _cron_profile_home(profile) url = _gateway_fire_endpoint(_profile_name, home) import httpx @@ -425,7 +412,6 @@ def _gateway_intentionally_stopped(profile: Optional[str]) -> bool: ``gateway_state`` runtime field: a legacy/crashed file must stay on the retryable-503 path. Any resolution or parse failure returns False (fail open toward retry). """ - from hermes_cli.web_server import _cron_profile_home import json as _json try: data = _json.loads((_cron_profile_home(profile)[1] / "gateway_state.json").read_text(encoding="utf-8")) diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 5898ec638b..ca54274b82 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -1,8 +1,4 @@ """Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging @@ -47,7 +43,7 @@ def _render_active_theme_bootstrap_css() -> str: references the variables rather than literals so runtime theme switches stay live: ``applyTheme()`` writes inline styles on ``documentElement`` which outrank this block. """ - from hermes_cli.web_server import load_config + from hermes_cli.config import load_config try: active = cfg_get(load_config(), "dashboard", "theme", default="default") if not active or not isinstance(active, str): @@ -621,7 +617,6 @@ def _plugin_auth_hint(name: str, provides_tools: list) -> tuple: A missing cache entry is "unknown": schedule a background probe rather than probing inline (which would starve the root event loop), so the short hub TTL picks it up. """ - from hermes_cli.web_server import _schedule_check_fn_probe try: from tools.registry import get_cached_check_fn_result, registry for tname in provides_tools: @@ -646,13 +641,9 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]: event loop). Only cached availability is consumed and the payload is memoized briefly to collapse the dashboard's bursty duplicate fetches. """ - from hermes_cli.web_server import ( - _discover_memory_provider_statuses, - _get_dashboard_plugins, - _normalize_memory_provider_name, - get_hermes_home, - load_config, - ) + from hermes_cli.web_server_memory import _discover_memory_provider_statuses, _normalize_memory_provider_name + from hermes_cli.web_server import _get_dashboard_plugins + from hermes_cli.config import get_hermes_home, load_config global _plugins_hub_cache, _plugins_hub_cache_expires_at now = time.monotonic() if not force_refresh: diff --git a/hermes_cli/web_server_files.py b/hermes_cli/web_server_files.py index dbb4807d85..4efc47144f 100644 --- a/hermes_cli/web_server_files.py +++ b/hermes_cli/web_server_files.py @@ -1,7 +1,4 @@ """Managed-files policy for the dashboard file browser: root resolution, path containment, entry metadata. - -Split out of ``hermes_cli.web_server``, which re-imports every externally used -name so ``web_server.`` keeps resolving (and monkeypatching) as before. """ import mimetypes @@ -99,7 +96,8 @@ def _dashboard_local_update_managed_externally() -> bool: the update button is the correct path. pip stays blocked in containers: its apply path mutates the running container filesystem. """ - from hermes_cli.web_server import PROJECT_ROOT, detect_install_method + from hermes_cli.web_server import PROJECT_ROOT + from hermes_cli.config import detect_install_method if _default_hermes_root_is_opt_data(): return True try: diff --git a/hermes_cli/web_server_gateway.py b/hermes_cli/web_server_gateway.py index 09203dccbf..c0d71fec35 100644 --- a/hermes_cli/web_server_gateway.py +++ b/hermes_cli/web_server_gateway.py @@ -1,9 +1,5 @@ """Gateway/process helpers for the dashboard: per-profile gateway topology (+cache), action subprocess spawning, gateway restart plumbing, system platform display. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there, so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging @@ -149,7 +145,6 @@ def _collect_profile_gateway_topology() -> Dict[str, Any]: (multiplex / single / multiple / none) and ``profile_platforms`` — ownership-filtered runtime platform maps per live gateway, an internal aggregation input never exposed directly. """ - from hermes_cli.web_server import _profile_gateway_writer_identity try: from hermes_cli.profiles import _check_gateway_running, profiles_to_serve from gateway.status import read_runtime_status @@ -213,7 +208,6 @@ def _topology_cache_get(fn: Any) -> Optional[Dict[str, Any]]: def _collect_profile_gateway_topology_cached() -> Dict[str, Any]: - from hermes_cli.web_server import _collect_profile_gateway_topology fn = _collect_profile_gateway_topology cached = _topology_cache_get(fn) if cached is not None: @@ -286,7 +280,6 @@ _ACTION_RESULTS: Dict[str, Dict[str, Any]] = {} def _terminate_desktop_managed_gateway() -> None: """Stop a live gateway restart child when its Desktop backend shuts down.""" - from hermes_cli.web_server import _ACTION_PROCS proc = _ACTION_PROCS.get("gateway-restart") if proc is None: return @@ -333,9 +326,7 @@ def _spawn_hermes_action( subcommand: List[str], name: str, *, env_overrides: Optional[Dict[str, str]] = None ) -> subprocess.Popen: """Spawn ``hermes `` detached (via ``hermes_cli.main``) and record the handle.""" - from hermes_cli.web_server import ( - PROJECT_ROOT, _ACTION_COMMANDS, _ACTION_IDS, _ACTION_LOG_DIR, _ACTION_PROCS, _ACTION_RESULTS, - ) + from hermes_cli.web_server import PROJECT_ROOT _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) log_file = open(_ACTION_LOG_DIR / _ACTION_LOG_FILES[name], "ab", buffering=0) log_file.write(f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()) @@ -365,7 +356,7 @@ def _spawn_hermes_action( def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]: - from hermes_cli.web_server import _profile_cli_args + from hermes_cli.web_server_profiles import _profile_cli_args return _profile_cli_args(profile) + ["gateway", verb] diff --git a/hermes_cli/web_server_lifecycle.py b/hermes_cli/web_server_lifecycle.py index 62a031a7cc..e3a2b1a6e4 100644 --- a/hermes_cli/web_server_lifecycle.py +++ b/hermes_cli/web_server_lifecycle.py @@ -1,7 +1,4 @@ """Serve-process lifecycle: parent death watchdog, port-conflict preflight, READY announcement, browser open, trusted proxies. - -Split out of ``hermes_cli.web_server``, which re-imports every externally used -name so ``web_server.`` keeps resolving (and monkeypatching) as before. """ import logging @@ -380,7 +377,6 @@ def _write_machine_sentinel_line(line: str) -> None: def _report_port_in_use(host: str, port: int) -> None: """Print the machine sentinel + a human hint naming likely holders.""" - from hermes_cli.web_server import _write_machine_sentinel_line _write_machine_sentinel_line(_PORT_IN_USE_SENTINEL.format(port=port)) print( f" Port {port} on {host} is already in use — likely another " diff --git a/hermes_cli/web_server_mcp.py b/hermes_cli/web_server_mcp.py index 2d28d907d4..3172dd6054 100644 --- a/hermes_cli/web_server_mcp.py +++ b/hermes_cli/web_server_mcp.py @@ -1,7 +1,5 @@ """MCP dashboard helpers: create-payload normalisation, env redaction/summary, dashboard-driven MCP OAuth worker. -Split out of ``hermes_cli.web_server``, which re-imports every externally used -name so ``web_server.`` keeps resolving (and monkeypatching) as before. Wraps the same config layer the CLI uses (hermes_cli.mcp_config); stdio ``env`` secrets are redacted on read. """ diff --git a/hermes_cli/web_server_memory.py b/hermes_cli/web_server_memory.py index 0bf315b6af..379dd81dd4 100644 --- a/hermes_cli/web_server_memory.py +++ b/hermes_cli/web_server_memory.py @@ -1,8 +1,4 @@ """Memory-provider dashboard helpers: manifest/schema loading, setup-env and dependency probes, configured-status discovery. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging @@ -140,7 +136,6 @@ def _run_setup_command( def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: - from hermes_cli.web_server import _dependency_importable pip_ok = all(_dependency_importable(dep) for dep in _string_list(setup.get("pip_dependencies"))) external_ok = True for dep in setup.get("external_dependencies") or []: @@ -226,7 +221,7 @@ def _read_json_file(path: Path) -> Dict[str, Any]: def _read_memory_provider_existing_values(name: str) -> Dict[str, Any]: """Best-effort read of existing provider config across legacy/native stores.""" - from hermes_cli.web_server import get_hermes_home, load_config + from hermes_cli.config import get_hermes_home, load_config hermes_home = get_hermes_home() values: Dict[str, Any] = {} @@ -259,7 +254,7 @@ def _read_memory_provider_existing_values(name: str) -> Dict[str, Any]: def _env_lookup(env_key: Optional[str]) -> str: - from hermes_cli.web_server import load_env + from hermes_cli.config import load_env if not env_key: return "" return str(load_env().get(env_key) or os.environ.get(env_key) or "") @@ -348,7 +343,7 @@ def _memory_provider_status(row: Dict[str, Any], setup: Dict[str, Any], configur def _discover_memory_provider_statuses() -> List[Dict[str, Any]]: - from hermes_cli.web_server import load_config + from hermes_cli.config import load_config discovered: Dict[str, Dict[str, Any]] = {} try: from plugins.memory import discover_memory_providers @@ -393,7 +388,6 @@ def _discover_memory_provider_statuses() -> List[Dict[str, Any]]: def _require_memory_provider_ready(name: str) -> None: - from hermes_cli.web_server import _discover_memory_provider_statuses if not name: return row = next((r for r in _discover_memory_provider_statuses() if r["name"] == name), None) diff --git a/hermes_cli/web_server_messaging.py b/hermes_cli/web_server_messaging.py index 2201231179..6e88d7bbc8 100644 --- a/hermes_cli/web_server_messaging.py +++ b/hermes_cli/web_server_messaging.py @@ -1,9 +1,5 @@ """Messaging-platform catalog and onboarding helpers: platform overrides/env discovery, WhatsApp and Telegram onboarding state. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there, so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging @@ -380,7 +376,7 @@ def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSes def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: - from hermes_cli.web_server import _restart_gateway_after + from hermes_cli.web_server_gateway import _restart_gateway_after return _restart_gateway_after(profile, what="WhatsApp onboarding", label="WhatsApp onboarding") diff --git a/hermes_cli/web_server_oauth.py b/hermes_cli/web_server_oauth.py index ceac055848..077f3c1da9 100644 --- a/hermes_cli/web_server_oauth.py +++ b/hermes_cli/web_server_oauth.py @@ -1,9 +1,5 @@ """Dashboard OAuth/login-status helpers: provider catalog, per-provider device pollers, Anthropic/Copilot/Claude-Code status probes. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported there, so -``web_server.`` keeps resolving (and monkeypatching). Helpers that tests patch on -``web_server`` are reached lazily through it. """ import logging @@ -216,7 +212,7 @@ def _oauth_poller(label: str): @_oauth_poller("nous") def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None: """Background poller that drives a Nous device-code flow to completion.""" - from hermes_cli.web_server import _profile_scope + from hermes_cli.web_server_profiles import _profile_scope from hermes_cli.auth import _poll_for_token, persist_nous_credentials, refresh_nous_oauth_from_state import httpx portal_base_url, client_id = sess["portal_base_url"], sess["client_id"] @@ -255,7 +251,7 @@ def _minimax_poller(session_id: str, sess: Dict[str, Any]) -> None: ``device_code``. Builds the same auth_state as the CLI's ``_minimax_oauth_login`` and persists via ``_minimax_save_auth_state`` so the system ends up as after ``hermes auth add minimax-oauth``. Region is fixed to "global" here; cn-region operators use the CLI's ``--region cn``.""" - from hermes_cli.web_server import _profile_scope + from hermes_cli.web_server_profiles import _profile_scope from hermes_cli.auth import ( _minimax_poll_token, _minimax_resolve_token_expiry_unix, _minimax_save_auth_state, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE, @@ -294,7 +290,7 @@ def _minimax_poller(session_id: str, sess: Dict[str, Any]) -> None: @_oauth_poller("xai") def _xai_device_poller(session_id: str, sess: Dict[str, Any]) -> None: """Background poller for xAI's OAuth device-code flow.""" - from hermes_cli.web_server import _profile_scope + from hermes_cli.web_server_profiles import _profile_scope import httpx from hermes_cli.auth import ( _save_xai_oauth_tokens, _xai_oauth_discovery, _xai_oauth_poll_device_token, diff --git a/hermes_cli/web_server_profiles.py b/hermes_cli/web_server_profiles.py index fcc64f90a4..f3d91b99a3 100644 --- a/hermes_cli/web_server_profiles.py +++ b/hermes_cli/web_server_profiles.py @@ -1,9 +1,5 @@ """Profile-scoped helpers: profile discovery fallback, profile dir/MCP-server writes, the profile/config scope context managers, skills-hub and tools/analytics catalog helpers. - -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. """ import logging diff --git a/hermes_cli/web_server_sessions.py b/hermes_cli/web_server_sessions.py index 38e0e5cdf2..24aff58a1c 100644 --- a/hermes_cli/web_server_sessions.py +++ b/hermes_cli/web_server_sessions.py @@ -1,9 +1,5 @@ """Session-DB access for the dashboard: per-profile SessionDB opening with schema heal, latest-descendant lookup and the auto-archive ticker. - -Split out of ``hermes_cli.web_server``, which re-imports every external name so -``web_server.`` keeps resolving (and monkeypatching); helpers tests patch -there are reached lazily through it. """ import logging @@ -111,8 +107,6 @@ def _open_session_db_at_path(db_path: Path, *, read_only: bool): healthy read path never takes a write lock. Tables outside SCHEMA_SQL (telemetry ``tel_*``, FTS shadow tables) are outside both probe and heal. """ - from hermes_cli.web_server import ( - _session_db_heal_exhausted, _session_db_heal_warned, _session_db_read_probe_statements) import sqlite3 from hermes_state import SessionDB, is_malformed_schema_error @@ -188,7 +182,7 @@ def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool): Access-mode semantics: see :func:`_open_session_db_at_path`. """ - from hermes_cli.web_server import _cron_profile_home + from hermes_cli.web_server_cron import _cron_profile_home from hermes_state import _default_db_path if profile: @@ -210,7 +204,6 @@ def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None: """Config-gated stale-session auto-archive for ``profile``; never raises. ``hermes serve`` runs neither CLI nor gateway startup hooks, so this session-list trigger is what makes ``sessions.auto_archive`` work there.""" - from hermes_cli.web_server import _open_session_db_for_profile try: key = profile or "" now = time.monotonic()