refactor(web_server): move skills-hub action helpers to web_server_profiles; trim fastapi import block to what web_server still uses

This commit is contained in:
Teknium
2026-09-02 16:19:10 -07:00
parent f9f21dcf25
commit 8b34d23366
3 changed files with 80 additions and 73 deletions
+10 -72
View File
@@ -13,7 +13,6 @@ from contextlib import asynccontextmanager
import asyncio
from collections import deque
import hashlib
import hmac
import logging
import os
@@ -30,7 +29,7 @@ import urllib.parse
from hermes_cli.install_identity import get_install_id as _shared_get_install_id
from hermes_cli.pty_session import run_reaper
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Optional, Tuple
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -57,11 +56,10 @@ from gateway.status import ( # noqa: F401 — late-bound by web_routers/status
)
try:
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
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
@@ -69,11 +67,10 @@ except ImportError:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("tool.dashboard", prompt=False)
from fastapi import (
FastAPI, HTTPException, Request,
)
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"
@@ -461,6 +458,7 @@ def _ssh_runtime_intact() -> bool:
return False
return (st.st_dev, st.st_ino) == (device, inode)
# In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the
# desktop app and the dashboard's own Chat tab both drive the agent over the
# `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an
@@ -1496,6 +1494,9 @@ app.include_router(_config_env_routes.router)
from hermes_cli.web_server_profiles import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server.<name>
_profile_cli_args,
_hub_action_name,
_installed_hub_identifiers,
_SKILLS_PROFILE_LOCK,
_TERMINAL_BACKENDS,
_approval_mode_of,
@@ -1703,38 +1704,6 @@ from hermes_cli.web_routers.ops import ( # noqa: E402,F401 — legacy re-export
# ---------------------------------------------------------------------------
def _profile_cli_args(profile: Optional[str]) -> List[str]:
"""Return ``["-p", <name>]`` for a validated non-default profile.
Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and
``_apply_profile_override()`` reads ``-p`` from argv in the child — the
only mechanism that reaches import-time-bound globals like
``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own
profile (no args, legacy behavior).
"""
requested = (profile or "").strip()
if not requested or requested.lower() in {"current", "default"}:
return []
from hermes_cli import profiles as profiles_mod
_resolve_profile_dir(requested)
return ["-p", profiles_mod.normalize_profile_name(requested)]
def _hub_action_name(verb: str, key: str) -> str:
"""Unique per-skill hub action name (+ registered log file).
``_spawn_hermes_action`` tracks one process/log per name, so a shared
"skills-install"/"skills-uninstall" would make concurrent row-level actions
overwrite each other's status/log while the UI polls per identifier. Slug
(readable) + hash (collision-proof) keys each action to its own row.
"""
slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")[:48] or "skill"
digest = hashlib.sha1(key.encode()).hexdigest()[:8]
name = f"skills-{verb}-{slug}-{digest}"
_ACTION_LOG_FILES.setdefault(name, f"action-{name}.log")
return name
from hermes_cli.web_routers import skills as _skills_routes # noqa: E402
app.include_router(_skills_routes.hub_router)
@@ -1749,37 +1718,6 @@ from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exp
)
def _installed_hub_identifiers(profile: Optional[str] = None) -> dict:
"""Map identifier -> installed lock entry for hub-installed skills.
Lets the UI mark search results that are already installed. Scoped to
``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an
explicit path, sidestepping the import-time LOCK_FILE binding).
Best-effort: returns an empty dict if the lock file can't be read.
"""
try:
from tools.skills_hub import HubLockFile
requested = (profile or "").strip()
if requested and requested.lower() != "current":
profile_dir = _resolve_profile_dir(requested)
lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json")
else:
lock = HubLockFile()
out = {}
for entry in lock.list_installed():
ident = entry.get("identifier")
if ident:
out[ident] = {
"name": entry.get("name"),
"trust_level": entry.get("trust_level"),
"scan_verdict": entry.get("scan_verdict"),
}
return out
except Exception:
return {}
app.include_router(_profiles_routes.router)
from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server.<name>
list_profiles_endpoint,
+4 -1
View File
@@ -7,7 +7,10 @@ Helpers that tests patch on ``web_server`` are reached lazily through it.
import threading
from pathlib import Path
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
if TYPE_CHECKING: # pragma: no cover - annotation only
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
from hermes_cli.config import redact_key
from hermes_cli.web_models import MCPServerCreate
+66
View File
@@ -6,7 +6,9 @@ Helpers that tests patch on ``web_server`` are reached lazily through it.
"""
import logging
import hashlib
import os
import re
import sys
import threading
from contextlib import contextmanager
@@ -15,6 +17,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.config import DEFAULT_CONFIG, get_process_hermes_home
from hermes_cli.web_models import MCPServerCreate
from hermes_cli.web_server_gateway import _ACTION_LOG_FILES
from hermes_cli.web_server_mcp import _normalize_mcp_server_create
# Same logger the code used before extraction (record parity).
@@ -521,3 +524,66 @@ def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
reverse=True,
)
return result
def _profile_cli_args(profile: Optional[str]) -> List[str]:
"""Return ``["-p", <name>]`` for a validated non-default profile.
Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and
``_apply_profile_override()`` reads ``-p`` from argv in the child — the
only mechanism that reaches import-time-bound globals like
``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own
profile (no args, legacy behavior).
"""
requested = (profile or "").strip()
if not requested or requested.lower() in {"current", "default"}:
return []
from hermes_cli import profiles as profiles_mod
_resolve_profile_dir(requested)
return ["-p", profiles_mod.normalize_profile_name(requested)]
def _hub_action_name(verb: str, key: str) -> str:
"""Unique per-skill hub action name (+ registered log file).
``_spawn_hermes_action`` tracks one process/log per name, so a shared
"skills-install"/"skills-uninstall" would make concurrent row-level actions
overwrite each other's status/log while the UI polls per identifier. Slug
(readable) + hash (collision-proof) keys each action to its own row.
"""
slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")[:48] or "skill"
digest = hashlib.sha1(key.encode()).hexdigest()[:8]
name = f"skills-{verb}-{slug}-{digest}"
_ACTION_LOG_FILES.setdefault(name, f"action-{name}.log")
return name
def _installed_hub_identifiers(profile: Optional[str] = None) -> dict:
"""Map identifier -> installed lock entry for hub-installed skills.
Lets the UI mark search results that are already installed. Scoped to
``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an
explicit path, sidestepping the import-time LOCK_FILE binding).
Best-effort: returns an empty dict if the lock file can't be read.
"""
try:
from tools.skills_hub import HubLockFile
requested = (profile or "").strip()
if requested and requested.lower() != "current":
profile_dir = _resolve_profile_dir(requested)
lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json")
else:
lock = HubLockFile()
out = {}
for entry in lock.list_installed():
ident = entry.get("identifier")
if ident:
out[ident] = {
"name": entry.get("name"),
"trust_level": entry.get("trust_level"),
"scan_verdict": entry.get("scan_verdict"),
}
return out
except Exception:
return {}