|
16 | 16 | import logging |
17 | 17 | import os |
18 | 18 | import secrets |
| 19 | +import subprocess |
19 | 20 | import sys |
20 | 21 | import threading |
21 | 22 | import time |
@@ -561,6 +562,138 @@ async def get_status(): |
561 | 562 | } |
562 | 563 |
|
563 | 564 |
|
| 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 | + |
564 | 697 | @app.get("/api/sessions") |
565 | 698 | async def get_sessions(limit: int = 20, offset: int = 0): |
566 | 699 | try: |
|
0 commit comments