diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 19f28d8d25..df5ccc48bd 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -1,14 +1,12 @@ """Profiles dashboard routes. -Two routers because route order matters: ``sessions_router`` -(/api/profiles/sessions*, projects/tree, pull-requests) was registered long -before the generic ``/api/profiles/{name}`` routes on ``router``; the original -global registration order is preserved rather than relying on Starlette's -literal-before-param matching. +Two routers because route order matters: ``sessions_router`` (/api/profiles/sessions*, +projects/tree, pull-requests) was registered long before the generic +``/api/profiles/{name}`` routes on ``router``; the original global registration order is +preserved rather than relying on Starlette's literal-before-param matching. -web_server-owned helpers are reached via the late-binding seam in -:mod:`hermes_cli.web_deps` so tests that ``monkeypatch.setattr(web_server, -"_helper", ...)`` keep working. +web_server-owned helpers are reached via the late-binding seam in :mod:`hermes_cli.web_deps` +so tests that ``monkeypatch.setattr(web_server, "_helper", ...)`` keep working. """ import contextlib @@ -24,32 +22,23 @@ import threading import time from collections import OrderedDict from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from fastapi import APIRouter, HTTPException, Query from hermes_cli.web_deps import late from hermes_cli.web_models import ( - ProfileCreate, - ProfileActiveUpdate, - ProfileExport, - ProfileImport, - ProfileRename, - ProfileSoulUpdate, - ProfileDescriptionUpdate, - ProfileModelUpdate, - ProfileDescribeAuto, - SessionPrScanBody, -) + ProfileCreate, ProfileActiveUpdate, ProfileExport, ProfileImport, ProfileRename, + ProfileSoulUpdate, ProfileDescriptionUpdate, ProfileModelUpdate, ProfileDescribeAuto, + SessionPrScanBody) +from hermes_cli.web_server_profiles import _hermes_home_scope # Same logger the handlers used before extraction (identical logger object). _log = logging.getLogger("hermes_cli.web_server") -# Per-profile session reads report failures in the response's ``errors`` -# array, which the desktop sidebar does not surface — an empty sidebar can -# look healthy while nothing logs. Warn once per (profile, message) per -# process so a persistent failure is loud in errors.log without turning every -# sidebar poll into log spam. +# Per-profile session reads report failures only in the response's ``errors`` array, which +# the desktop sidebar does not surface. Warn once per (profile, message) per process so a +# persistent failure is loud in errors.log without turning every sidebar poll into spam. _profile_read_warned: set = set() @@ -64,8 +53,7 @@ def _warn_profile_read_error(profile: str, exc: Exception) -> None: sessions_router = APIRouter() router = APIRouter() -# Late-bound web_server helpers (resolved at call time; cycle-safe, -# monkeypatch-transparent). +# Late-bound web_server helpers (resolved at call time; cycle-safe, monkeypatch-transparent). _cron_profile_home = late("_cron_profile_home") _fallback_profile_dicts = late("_fallback_profile_dicts") _hub_action_name = late("_hub_action_name") @@ -84,31 +72,22 @@ _normalize_main_model_assignment = late("_normalize_main_model_assignment") # --------------------------------------------------------------------------- -def _profile_attr(info, name: str, default: Any = None) -> Any: - try: - return getattr(info, name) - except Exception: - return default - - def _profile_to_dict(info) -> Dict[str, Any]: + attr = functools.partial(getattr, info) return { - "name": _profile_attr(info, "name", ""), - "path": str(_profile_attr(info, "path", "")), - "is_default": bool(_profile_attr(info, "is_default", False)), - "model": _profile_attr(info, "model"), - "provider": _profile_attr(info, "provider"), - "has_env": bool(_profile_attr(info, "has_env", False)), - "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), - "gateway_running": bool(_profile_attr(info, "gateway_running", False)), - "description": _profile_attr(info, "description", "") or "", - "description_auto": bool(_profile_attr(info, "description_auto", False)), - "display_name": _profile_attr(info, "display_name", "") or "", - "distribution_name": _profile_attr(info, "distribution_name"), - "distribution_version": _profile_attr(info, "distribution_version"), - "distribution_source": _profile_attr(info, "distribution_source"), - "has_alias": _profile_attr(info, "alias_path") is not None, - } + "name": attr("name", ""), "path": str(attr("path", "")), + "is_default": bool(attr("is_default", False)), + "model": attr("model", None), "provider": attr("provider", None), + "has_env": bool(attr("has_env", False)), + "skill_count": int(attr("skill_count", 0) or 0), + "gateway_running": bool(attr("gateway_running", False)), + "description": attr("description", "") or "", + "description_auto": bool(attr("description_auto", False)), + "display_name": attr("display_name", "") or "", + "distribution_name": attr("distribution_name", None), + "distribution_version": attr("distribution_version", None), + "distribution_source": attr("distribution_source", None), + "has_alias": attr("alias_path", None) is not None} def _profile_setup_command(name: str) -> str: @@ -118,76 +97,49 @@ def _profile_setup_command(name: str) -> str: def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None: - """Write the main model assignment into a specific profile's config.yaml. - - Scopes ``load_config``/``save_config`` to ``profile_dir`` via the - context-local HERMES_HOME override so the write lands in the target - profile's config rather than the dashboard process's active profile. - Clears any stale ``base_url`` / ``context_length`` the same way - ``POST /api/model/set`` does, since the new model may differ. - """ + """Write the main model assignment into ``profile_dir``'s config.yaml (HERMES_HOME-scoped); + clears stale ``base_url`` / ``context_length`` like ``POST /api/model/set`` does.""" from hermes_cli.web_server import load_config, save_config - from hermes_constants import set_hermes_home_override, reset_hermes_home_override - - token = set_hermes_home_override(str(profile_dir)) - try: + with _hermes_home_scope(profile_dir): provider, model = _normalize_main_model_assignment(provider, model) cfg = load_config() cfg["model"] = _apply_main_model_assignment(cfg.get("model", {}), provider, model) save_config(cfg) - finally: - reset_hermes_home_override(token) def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: - """Disable every installed skill in ``profile_dir`` not in ``keep``. - - Profiles manage skill activation via a *disabled* list — all installed - skills are active by default and users opt out. The builder's skill step - uses "replace" semantics: the user picks exactly which seeded built-in / - optional skills stay active, and everything else gets added to the disabled - list. (Hub skills are installed separately via subprocess and are active on - install.) Scoped to the profile via the HERMES_HOME override. Returns the - number of skills newly disabled. - """ + """Disable every installed skill in ``profile_dir`` not in ``keep``; returns how many were + newly disabled. Profiles manage activation via a *disabled* list (everything installed is + active by default); the builder's skill step has "replace" semantics. Hub skills are + installed separately via subprocess and are active on install.""" from hermes_cli.web_server import load_config - from hermes_constants import set_hermes_home_override, reset_hermes_home_override from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills - keep_set = {s.strip() for s in keep if s and s.strip()} - disabled_count = 0 - token = set_hermes_home_override(str(profile_dir)) - try: - installed: List[str] = [] + with _hermes_home_scope(profile_dir): skills_root = profile_dir / "skills" - if skills_root.is_dir(): - for md in skills_root.rglob("SKILL.md"): - installed.append(md.parent.name) + installed = ([md.parent.name for md in skills_root.rglob("SKILL.md")] + if skills_root.is_dir() else []) cfg = load_config() disabled = get_disabled_skills(cfg) + newly = 0 for name in installed: if name not in keep_set and name not in disabled: disabled.add(name) - disabled_count += 1 - if disabled_count: + newly += 1 + if newly: save_disabled_skills(cfg, disabled) - finally: - reset_hermes_home_override(token) - return disabled_count + return newly -# Returned by the offloaded file readers below to mean "the file is not there", -# which a plain ``None`` cannot express: ``desktop.json`` may legitimately hold -# the document ``null``, and that is an existing-but-empty overlay rather than -# an absent one. +# Returned by the offloaded file readers below to mean "the file is not there", which a plain +# ``None`` cannot express: ``desktop.json`` may legitimately hold the document ``null``. _MISSING = object() @contextlib.contextmanager def _profile_errors(log_msg: str, *args, not_found=(FileNotFoundError,), bad_request=(ValueError,)): - """Map hermes_cli.profiles exceptions to HTTP: ``not_found`` -> 404, - ``bad_request`` -> 400 (checked in that order), anything else is logged - with ``log_msg`` and becomes a 500. ``HTTPException`` passes through.""" + """Map hermes_cli.profiles exceptions to HTTP: ``not_found`` -> 404, ``bad_request`` -> 400 + (in that order), anything else is logged with ``log_msg`` -> 500. HTTPException passes.""" try: yield except HTTPException: @@ -201,9 +153,17 @@ def _profile_errors(log_msg: str, *args, not_found=(FileNotFoundError,), raise HTTPException(status_code=500, detail=str(e)) +async def _read_off_loop(read, label: str, errors): + """``read()`` on a worker thread; ``errors`` become ``500 "Could not read