Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
save_env_value,
remove_env_value,
check_config_version,
detect_install_method,
format_docker_update_message,
recommended_update_command_for_method,
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
Expand Down Expand Up @@ -772,6 +775,26 @@ async def get_status():
# report liveness and exit code without shelling out to ``ps``.
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}

# ``name`` → completed synthetic action result for actions the server handled
# without spawning a subprocess (for example, unsupported Docker updates).
_ACTION_RESULTS: Dict[str, Dict[str, Any]] = {}


def _record_completed_action(name: str, message: str, exit_code: int = 1) -> None:
"""Record a non-spawned action result and write it to the action log."""
log_file_name = _ACTION_LOG_FILES[name]
_ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = _ACTION_LOG_DIR / log_file_name
with open(log_path, "ab", buffering=0) as log_file:
log_file.write(
f"\n=== {name} completed {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode()
)
log_file.write(message.encode("utf-8", errors="replace"))
if not message.endswith("\n"):
log_file.write(b"\n")
_ACTION_PROCS.pop(name, None)
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}


def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
"""Spawn ``hermes <subcommand>`` detached and record the Popen handle.
Expand Down Expand Up @@ -805,6 +828,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
popen_kwargs["start_new_session"] = True

proc = subprocess.Popen(cmd, **popen_kwargs)
_ACTION_RESULTS.pop(name, None)
_ACTION_PROCS[name] = proc
return proc

Expand Down Expand Up @@ -841,6 +865,19 @@ async def restart_gateway():
@app.post("/api/hermes/update")
async def update_hermes():
"""Kick off ``hermes update`` in the background."""
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
message = format_docker_update_message()
_record_completed_action("hermes-update", message, exit_code=1)
return {
"ok": False,
"pid": None,
"name": "hermes-update",
"error": "docker_update_unsupported",
"message": message,
"update_command": recommended_update_command_for_method(install_method),
}

try:
proc = _spawn_hermes_action(["update"], "hermes-update")
except Exception as exc:
Expand Down Expand Up @@ -1065,9 +1102,10 @@ async def get_action_status(name: str, lines: int = 200):

proc = _ACTION_PROCS.get(name)
if proc is None:
result = _ACTION_RESULTS.get(name)
running = False
exit_code: Optional[int] = None
pid: Optional[int] = None
exit_code = result.get("exit_code") if result else None
pid = result.get("pid") if result else None
else:
exit_code = proc.poll()
running = exit_code is None
Expand Down
60 changes: 60 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,66 @@ def test_speak_text_requires_nonempty_text(self):
resp = self.client.post("/api/audio/speak", json={"text": " "})
assert resp.status_code == 400

def test_update_hermes_returns_docker_guidance_without_spawning(self, monkeypatch):
import hermes_cli.web_server as web_server

spawned = False

def fail_spawn(*_args, **_kwargs):
nonlocal spawned
spawned = True
raise AssertionError("docker update guard should not spawn hermes update")

monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "docker")
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)

resp = self.client.post("/api/hermes/update")

assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["name"] == "hermes-update"
assert data["pid"] is None
assert data["error"] == "docker_update_unsupported"
assert "docker pull nousresearch/hermes-agent:latest" in data["message"]
assert spawned is False

status = self.client.get("/api/actions/hermes-update/status")
assert status.status_code == 200
status_data = status.json()
assert status_data["running"] is False
assert status_data["exit_code"] == 1
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])

def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch):
import hermes_cli.web_server as web_server

class Proc:
pid = 12345

def poll(self):
return None

calls = []

def fake_spawn(subcommand, name):
calls.append((subcommand, name))
return Proc()

monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)

resp = self.client.post("/api/hermes/update")

assert resp.status_code == 200
assert resp.json() == {"ok": True, "pid": 12345, "name": "hermes-update"}
assert calls == [(["update"], "hermes-update")]

def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
Expand Down
5 changes: 4 additions & 1 deletion web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,10 @@ export interface AuthMeResponse {
export interface ActionResponse {
name: string;
ok: boolean;
pid: number;
pid: number | null;
error?: string;
message?: string;
update_command?: string;
}

/** Per-call overrides for {@link fetchJSON}. */
Expand Down
Loading