Skip to content

Commit fab1bb6

Browse files
committed
Merge pull request NousResearch#13526 from NousResearch/feat/dashboard-action-buttons
feat: add buttons to update hermes and restart gateway
2 parents 918bbbe + c501e87 commit fab1bb6

9 files changed

Lines changed: 497 additions & 71 deletions

File tree

hermes_cli/web_server.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import logging
1717
import os
1818
import secrets
19+
import subprocess
1920
import sys
2021
import threading
2122
import time
@@ -561,6 +562,138 @@ async def get_status():
561562
}
562563

563564

565+
# ---------------------------------------------------------------------------
566+
# Gateway + update actions (invoked from the Status page).
567+
#
568+
# Both commands are spawned as detached subprocesses so the HTTP request
569+
# returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()``
570+
# calls fail fast with EOF rather than hanging forever. stdout/stderr are
571+
# streamed to a per-action log file under ``~/.hermes/logs/<action>.log`` so
572+
# the dashboard can tail them back to the user.
573+
# ---------------------------------------------------------------------------
574+
575+
_ACTION_LOG_DIR: Path = get_hermes_home() / "logs"
576+
577+
# Short ``name`` (from the URL) → absolute log file path.
578+
_ACTION_LOG_FILES: Dict[str, str] = {
579+
"gateway-restart": "gateway-restart.log",
580+
"hermes-update": "hermes-update.log",
581+
}
582+
583+
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
584+
# report liveness and exit code without shelling out to ``ps``.
585+
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
586+
587+
588+
def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
589+
"""Spawn ``hermes <subcommand>`` detached and record the Popen handle.
590+
591+
Uses the running interpreter's ``hermes_cli.main`` module so the action
592+
inherits the same venv/PYTHONPATH the web server is using.
593+
"""
594+
log_file_name = _ACTION_LOG_FILES[name]
595+
_ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True)
596+
log_path = _ACTION_LOG_DIR / log_file_name
597+
log_file = open(log_path, "ab", buffering=0)
598+
log_file.write(
599+
f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()
600+
)
601+
602+
cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand]
603+
604+
popen_kwargs: Dict[str, Any] = {
605+
"cwd": str(PROJECT_ROOT),
606+
"stdin": subprocess.DEVNULL,
607+
"stdout": log_file,
608+
"stderr": subprocess.STDOUT,
609+
"env": {**os.environ, "HERMES_NONINTERACTIVE": "1"},
610+
}
611+
if sys.platform == "win32":
612+
popen_kwargs["creationflags"] = (
613+
subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
614+
| getattr(subprocess, "DETACHED_PROCESS", 0)
615+
)
616+
else:
617+
popen_kwargs["start_new_session"] = True
618+
619+
proc = subprocess.Popen(cmd, **popen_kwargs)
620+
_ACTION_PROCS[name] = proc
621+
return proc
622+
623+
624+
def _tail_lines(path: Path, n: int) -> List[str]:
625+
"""Return the last ``n`` lines of ``path``. Reads the whole file — fine
626+
for our small per-action logs. Binary-decoded with ``errors='replace'``
627+
so log corruption doesn't 500 the endpoint."""
628+
if not path.exists():
629+
return []
630+
try:
631+
text = path.read_text(errors="replace")
632+
except OSError:
633+
return []
634+
lines = text.splitlines()
635+
return lines[-n:] if n > 0 else lines
636+
637+
638+
@app.post("/api/gateway/restart")
639+
async def restart_gateway():
640+
"""Kick off a ``hermes gateway restart`` in the background."""
641+
try:
642+
proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
643+
except Exception as exc:
644+
_log.exception("Failed to spawn gateway restart")
645+
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
646+
return {
647+
"ok": True,
648+
"pid": proc.pid,
649+
"name": "gateway-restart",
650+
}
651+
652+
653+
@app.post("/api/hermes/update")
654+
async def update_hermes():
655+
"""Kick off ``hermes update`` in the background."""
656+
try:
657+
proc = _spawn_hermes_action(["update"], "hermes-update")
658+
except Exception as exc:
659+
_log.exception("Failed to spawn hermes update")
660+
raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}")
661+
return {
662+
"ok": True,
663+
"pid": proc.pid,
664+
"name": "hermes-update",
665+
}
666+
667+
668+
@app.get("/api/actions/{name}/status")
669+
async def get_action_status(name: str, lines: int = 200):
670+
"""Tail an action log and report whether the process is still running."""
671+
log_file_name = _ACTION_LOG_FILES.get(name)
672+
if log_file_name is None:
673+
raise HTTPException(status_code=404, detail=f"Unknown action: {name}")
674+
675+
log_path = _ACTION_LOG_DIR / log_file_name
676+
tail = _tail_lines(log_path, min(max(lines, 1), 2000))
677+
678+
proc = _ACTION_PROCS.get(name)
679+
if proc is None:
680+
running = False
681+
exit_code: Optional[int] = None
682+
pid: Optional[int] = None
683+
else:
684+
exit_code = proc.poll()
685+
running = exit_code is None
686+
pid = proc.pid
687+
688+
return {
689+
"name": name,
690+
"running": running,
691+
"exit_code": exit_code,
692+
"pid": pid,
693+
"lines": tail,
694+
}
695+
696+
564697
@app.get("/api/sessions")
565698
async def get_sessions(limit: int = 20, offset: int = 0):
566699
try:

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui-tui/package-lock.json

Lines changed: 15 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)