Files
hermes-agent/tests/hermes_cli/test_update_receipt_endpoint.py
Teknium 692ed13e59 fix: the dashboard update status certifies an outcome only from its own action's receipt
The receipt store moved to the root home, so profile A's later success in
latest.json became profile B's exit 0 once B's dashboard lost its in-memory
result: the receipt fallback had no identity to compare. Each receipt now
records the dashboard action (HERMES_ACTION_ID) that spawned the run, and the
status route resolves the action this dashboard started (its log header, else
the in-memory id), reads that action's receipt from latest.json or the
retained archive, and maps an outcome to an exit code only on an exact match.
The attached summary carries action_id so clients can tell whose run it is;
the update_id/PM correlation identity is unchanged.
2026-10-06 03:44:49 -07:00

154 lines
5.7 KiB
Python

"""Phase-1 bullet 3 (#91277): the dashboard/Desktop READ the update receipt.
The update receipt (written by every `hermes update` run since #91283,
`latest.json` pointer) is the durable outcome record. These tests pin:
- GET /api/hermes/update/receipt returns the full receipt + summary; 404
when none exists.
- GET /api/actions/hermes-update/status attaches the receipt summary, and
uses a finished receipt as the outcome when both in-memory registries AND
the log marker are gone (dashboard restarted + log rotated).
"""
import json
from pathlib import Path
import pytest
import hermes_cli.web_server as web_server
import hermes_cli.web_server_gateway as _web_server_gateway
@pytest.fixture()
def client():
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
_ACTION_ID = "d" * 32
def _write_receipt(tmp_path: Path, monkeypatch, *, outcome="success") -> dict:
receipt = {
"schema": 1,
"started_at": "2026-08-23T07:00:00+00:00",
"finished_at": "2026-08-23T07:03:20+00:00",
"argv": ["hermes", "update"],
"pid": 12345,
"outcome": outcome,
"action_id": _ACTION_ID,
"pre_update": {"sha": "a" * 40, "version": "0.20.4"},
"post_update": {"sha": "b" * 40, "version": "0.20.5"},
"steps": [{"name": "pre_update_backup", "ok": True, "detail": "", "at": ""}],
"skips": [],
"gateway_restart": {},
"fleet": [
{"profile": "default", "pid": 1, "code_sha": "b" * 40, "state": "current"}
],
}
receipt_dir = tmp_path / "logs" / "update_receipts"
receipt_dir.mkdir(parents=True)
(receipt_dir / "latest.json").write_text(json.dumps(receipt), encoding="utf-8")
import hermes_cli.update_receipt as ur
monkeypatch.setattr(ur, "_receipt_dir", lambda: receipt_dir)
return receipt
class TestUpdateReceiptEndpoint:
def test_receipt_endpoint_returns_full_receipt_and_summary(self, client, tmp_path, monkeypatch):
receipt = _write_receipt(tmp_path, monkeypatch)
resp = client.get("/api/hermes/update/receipt")
assert resp.status_code == 200
data = resp.json()
assert data["receipt"]["outcome"] == "success"
assert data["receipt"]["steps"] == receipt["steps"]
summary = data["summary"]
assert summary["outcome"] == "success"
assert summary["pre_sha"] == "a" * 40
assert summary["post_sha"] == "b" * 40
assert summary["post_version"] == "0.20.5"
assert summary["fleet_states"] == ["current"]
def test_receipt_endpoint_404_when_no_receipt(self, client, tmp_path, monkeypatch):
import hermes_cli.update_receipt as ur
monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "none")
resp = client.get("/api/hermes/update/receipt")
assert resp.status_code == 404
class TestUpdateStatusReadsReceipt:
def _clear_registries(self, monkeypatch, tmp_path):
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path / "actions")
(tmp_path / "actions").mkdir(exist_ok=True)
# The dashboard's own log names the action it spawned; the receipt must name the same one.
(tmp_path / "actions" / "hermes-update.log").write_text(
f"=== hermes-update started 2026-08-23 07:00:00 {_ACTION_ID} ===\n", encoding="utf-8")
monkeypatch.setattr(_web_server_gateway, "_ACTION_PROCS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_RESULTS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_COMMANDS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_IDS", {})
def test_status_attaches_receipt_summary(self, client, tmp_path, monkeypatch):
_write_receipt(tmp_path, monkeypatch)
self._clear_registries(monkeypatch, tmp_path)
resp = client.get("/api/actions/hermes-update/status")
assert resp.status_code == 200
data = resp.json()
assert data["receipt"]["outcome"] == "success"
def test_finished_receipt_is_the_outcome_when_marker_and_memory_gone(self, client, tmp_path, monkeypatch):
"""Dashboard restarted (registries lost) AND update.log has no
completion marker (rotated): the receipt alone reports success."""
_write_receipt(tmp_path, monkeypatch, outcome="success")
self._clear_registries(monkeypatch, tmp_path)
resp = client.get("/api/actions/hermes-update/status")
data = resp.json()
assert data["running"] is False
assert data["exit_code"] == 0
def test_partial_receipt_maps_to_exit_1(self, client, tmp_path, monkeypatch):
_write_receipt(tmp_path, monkeypatch, outcome="partial")
self._clear_registries(monkeypatch, tmp_path)
resp = client.get("/api/actions/hermes-update/status")
assert resp.json()["exit_code"] == 1
def test_running_receipt_proves_nothing(self, client, tmp_path, monkeypatch):
"""An unfinished receipt (crashed run / mid-update) must not report
an outcome — clients keep polling."""
receipt = _write_receipt(tmp_path, monkeypatch, outcome="running")
self._clear_registries(monkeypatch, tmp_path)
resp = client.get("/api/actions/hermes-update/status")
assert resp.json()["exit_code"] is None
def test_non_update_actions_untouched(self, client, tmp_path, monkeypatch):
_write_receipt(tmp_path, monkeypatch)
self._clear_registries(monkeypatch, tmp_path)
resp = client.get("/api/actions/gateway-restart/status")
data = resp.json()
assert "receipt" not in data