"""Dashboard update status reads the ROOT home's shared update.log, correlated by action id.""" import types import pytest import hermes_cli.web_server_gateway as _web_server_gateway class TestUpdateStatusRootLog: @pytest.fixture(autouse=True) def _setup_test_client(self, monkeypatch, _isolate_hermes_home): """Create a TestClient and isolate the state DB under the test HERMES_HOME.""" try: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") import hermes_state from hermes_constants import get_hermes_home from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") self.client = TestClient(app) self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN def test_update_status_recovers_completed_result_after_dashboard_restart(self, monkeypatch, tmp_path): # The dashboard runs under a profile home; ``hermes update`` mirrors to the ROOT home's # update.log (main_dashboard), so the durable completion marker is read there. root = tmp_path / "root" (root / "logs").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "coder")) action_id = "c" * 32 (tmp_path / "hermes-update.log").write_text( f"=== hermes-update started 2026-08-17 11:19:34 {action_id} ===\n" "pulling updates...\n", encoding="utf-8", ) (root / "logs" / "update.log").write_text( "=== hermes update started 2026-08-17T11:19:35 ===\n" "✓ Update complete!\n" f"=== hermes-update completed {action_id} ===\n", encoding="utf-8", ) monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path) 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", {}) status = self.client.get("/api/actions/hermes-update/status?lines=2000") assert status.status_code == 200 data = status.json() assert data["running"] is False assert data["exit_code"] == 0 assert data["action_id"] == action_id assert f"=== hermes-update completed {action_id} ===" in data["lines"] def test_update_status_ignores_another_profiles_completion_in_the_shared_root_log(self, monkeypatch, tmp_path): # Every profile's ``hermes update`` mirrors into the ROOT update.log; profile A's success # there must not certify the action profile B's dashboard started (and lost on restart). root = tmp_path / "root" (root / "logs").mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b")) b_logs = tmp_path / "b-logs" monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", b_logs) for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"): monkeypatch.setattr(_web_server_gateway, registry, {}) monkeypatch.setattr(_web_server_gateway.subprocess, "Popen", lambda *a, **kw: types.SimpleNamespace(pid=7)) b_id = "b" * 32 _web_server_gateway._spawn_hermes_action(["update"], "hermes-update", env_overrides={"HERMES_ACTION_ID": b_id}) _web_server_gateway._ACTION_PROCS.clear() # B's dashboard restarted: in-memory result lost def status_after_root_completion(action_id): (root / "logs" / "update.log").write_text( "=== hermes update started 2026-08-17T11:19:35 ===\n" f"=== hermes-update completed {action_id} ===\n", encoding="utf-8") return self.client.get("/api/actions/hermes-update/status?lines=2000").json() foreign = status_after_root_completion("a" * 32) assert foreign["exit_code"] is None assert "action_id" not in foreign own = status_after_root_completion(b_id) assert own["exit_code"] == 0 assert own["action_id"] == b_id @pytest.mark.parametrize("log_shape,b_outcome", [ ("short", "success"), ("long", "success"), ("rotated", "success"), ("long", "failed"), ("long", None)]) def test_restarted_dashboard_keeps_its_action_identity_past_the_log_tail( self, monkeypatch, tmp_path, log_shape, b_outcome): # The status route reads at most 2,000 log lines: build output (or rotation) pushing the # start header out must not turn B's own success into exit null ("Action failed (exit ?)"), # nor let profile A's newer latest receipt answer for B. from hermes_cli import update_receipt root, b_id, a_id = tmp_path / "root", "b" * 32, "a" * 32 (root / "logs").mkdir(parents=True) b_logs = tmp_path / "b-logs" monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", b_logs) for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"): monkeypatch.setattr(_web_server_gateway, registry, {}) monkeypatch.setattr(_web_server_gateway.subprocess, "Popen", lambda *a, **kw: types.SimpleNamespace(pid=7)) monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b")) _web_server_gateway._spawn_hermes_action(["update"], "hermes-update", env_overrides={"HERMES_ACTION_ID": b_id}) for profile, action_id, outcome in (("b", b_id, b_outcome), ("a", a_id, "success")): if outcome: monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / profile)) monkeypatch.setenv("HERMES_ACTION_ID", action_id) update_receipt.begin_update_receipt() update_receipt.finalize_update_receipt(outcome) monkeypatch.delenv("HERMES_ACTION_ID") monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b")) log = b_logs / "hermes-update.log" if log_shape == "long": with log.open("a", encoding="utf-8") as handle: handle.write("build output\n" * 2001) elif log_shape == "rotated": log.unlink() if b_outcome == "success": (root / "logs" / "update.log").write_text(f"=== hermes-update completed {b_id} ===\n", encoding="utf-8") _web_server_gateway._ACTION_PROCS.clear() # dashboard restarted: in-memory registries lost _web_server_gateway._ACTION_IDS.clear() data = self.client.get("/api/actions/hermes-update/status?lines=2000").json() assert data["running"] is False assert data["exit_code"] == (0 if b_outcome == "success" else None) assert data.get("action_id") == (b_id if b_outcome == "success" else None) assert data["receipt"]["action_id"] == (b_id if b_outcome else a_id) @pytest.mark.parametrize("b_outcome", [None, "failed", "success"]) def test_root_latest_receipt_of_another_profile_never_certifies_this_action(self, monkeypatch, tmp_path, b_outcome): # The receipt store is root-wide too: A's later success in latest.json must not become B's # exit 0. Only a receipt naming B's action (found in the archive when A is latest) counts. from hermes_cli import update_receipt root = tmp_path / "root" (root / "logs").mkdir(parents=True) b_id, a_id = "b" * 32, "a" * 32 for profile, action_id, outcome in (("b", b_id, b_outcome), ("a", a_id, "success")): if outcome: monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / profile)) monkeypatch.setenv("HERMES_ACTION_ID", action_id) update_receipt.begin_update_receipt() update_receipt.finalize_update_receipt(outcome) monkeypatch.delenv("HERMES_ACTION_ID") monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "b")) assert update_receipt.read_latest_receipt()["action_id"] == a_id (tmp_path / "hermes-update.log").write_text( f"=== hermes-update started 2026-08-17 11:19:34 {b_id} ===\n", encoding="utf-8") (root / "logs" / "update.log").write_text(f"=== hermes-update completed {a_id} ===\n", encoding="utf-8") monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path) for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"): monkeypatch.setattr(_web_server_gateway, registry, {}) data = self.client.get("/api/actions/hermes-update/status?lines=2000").json() assert data["exit_code"] == (0 if b_outcome == "success" else None) assert "action_id" not in data # A's root completion marker is not B's # The attached receipt names its writer: B's own when it exists, else A's (never B's). assert data["receipt"]["action_id"] == (b_id if b_outcome else a_id) assert data["receipt"]["outcome"] == (b_outcome or "success") @pytest.mark.parametrize("debt", ["none", "followups", "user_action"]) def test_status_receipt_carries_owed_steps_of_a_committed_update(self, monkeypatch, tmp_path, debt): # C3: owed post-commit steps keep the run a success (exit 0); the summary must still name # them so the dashboard and Desktop report the debt instead of plain success. from hermes_cli import update_receipt root, action_id = tmp_path / "root", "c" * 32 monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "coder")) monkeypatch.setenv("HERMES_ACTION_ID", action_id) update_receipt.begin_update_receipt() if debt == "followups": update_receipt.record_followup("dependencies", "synthetic selected-interpreter sync failed") update_receipt.record_followup("config_migration", "disk full") elif debt == "user_action": update_receipt.record_user_action("autostash", "re-apply the parked stash") update_receipt.finalize_update_receipt("success") (tmp_path / "hermes-update.log").write_text( f"=== hermes-update started 2026-08-17 11:19:34 {action_id} ===\n", encoding="utf-8") monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path) for registry in ("_ACTION_PROCS", "_ACTION_RESULTS", "_ACTION_COMMANDS", "_ACTION_IDS"): monkeypatch.setattr(_web_server_gateway, registry, {}) data = self.client.get("/api/actions/hermes-update/status?lines=2000").json() receipt = data["receipt"] assert [f["step"] for f in receipt["followups"]] == ( ["dependencies", "config_migration"] if debt == "followups" else []) assert receipt["user_action"] == ( {"step": "autostash", "reason": "re-apply the parked stash"} if debt == "user_action" else None) # Debt never changes the C3 exit mapping: owed follow-ups stay exit 0, a user action is partial. assert data["exit_code"] == (1 if debt == "user_action" else 0)