213 lines
8.8 KiB
Python
213 lines
8.8 KiB
Python
"""Deleted named profiles must stay gone until explicitly recreated.
|
|
|
|
A live serve/logging process can mkdir ``profiles/<name>/logs`` after
|
|
``hermes profile delete`` removes the tree. That empty shell then
|
|
reappears in ``hermes profile list`` and Desktop Bot Mode. These tests
|
|
lock the tombstone + no-mkdir contract without depending on Desktop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.config import ensure_hermes_home
|
|
from hermes_cli.profiles import (
|
|
backfill_profile_envs,
|
|
create_profile,
|
|
delete_profile,
|
|
list_profiles,
|
|
profile_exists,
|
|
profiles_to_serve,
|
|
resolve_profile_env,
|
|
set_active_profile,
|
|
)
|
|
from hermes_constants import named_profile_home
|
|
from hermes_logging import setup_logging
|
|
|
|
|
|
@pytest.fixture()
|
|
def profile_env(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
return tmp_path
|
|
|
|
|
|
def _named_homes(tmp_path: Path) -> list[str]:
|
|
return [info.name for info in list_profiles() if not info.is_default]
|
|
|
|
|
|
def _delete(name: str) -> None:
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
|
|
"hermes_cli.profiles._stop_profile_backends"
|
|
):
|
|
delete_profile(name, yes=True)
|
|
|
|
|
|
class TestDeletedProfileTombstone:
|
|
def test_delete_then_logging_setup_does_not_recreate_home(self, profile_env, monkeypatch):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
|
|
"hermes_cli.profiles._stop_profile_backends"
|
|
):
|
|
delete_profile("worker", yes=True)
|
|
|
|
assert not profile_dir.exists()
|
|
assert "worker" not in _named_homes(profile_env)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
|
with pytest.raises(FileNotFoundError, match="Named profile home does not exist"):
|
|
setup_logging(hermes_home=profile_dir, force=True)
|
|
|
|
assert not profile_dir.exists()
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_env / ".hermes"))
|
|
assert "worker" not in _named_homes(profile_env)
|
|
|
|
def test_empty_shell_after_delete_is_not_listed_or_served(self, profile_env):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
|
|
"hermes_cli.profiles._stop_profile_backends"
|
|
):
|
|
delete_profile("worker", yes=True)
|
|
|
|
# Simulate a stale mkdir that only rebuilds the directory itself.
|
|
profile_dir.mkdir(parents=True)
|
|
(profile_dir / "state.db").write_bytes(b"")
|
|
|
|
assert "worker" not in _named_homes(profile_env)
|
|
served = [name for name, _ in profiles_to_serve(True)]
|
|
assert "worker" not in served
|
|
|
|
def test_tombstoned_home_is_not_bootstrapped(self, profile_env, monkeypatch):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
|
|
"hermes_cli.profiles._stop_profile_backends"
|
|
):
|
|
delete_profile("worker", yes=True)
|
|
profile_dir.mkdir(parents=True)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
|
with pytest.raises(FileNotFoundError, match="Named profile home does not exist"):
|
|
ensure_hermes_home()
|
|
assert not (profile_dir / "sessions").exists()
|
|
|
|
def test_create_after_delete_clears_tombstone(self, profile_env):
|
|
create_profile("worker", no_alias=True, no_skills=True)
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), patch(
|
|
"hermes_cli.profiles._stop_profile_backends"
|
|
):
|
|
delete_profile("worker", yes=True)
|
|
|
|
recreated = create_profile("worker", no_alias=True, no_skills=True)
|
|
assert recreated.is_dir()
|
|
assert "worker" in _named_homes(profile_env)
|
|
|
|
def test_profile_exists_is_false_for_tombstoned_shell(self, profile_env):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
assert profile_exists("worker") is True
|
|
_delete("worker")
|
|
profile_dir.mkdir(parents=True)
|
|
|
|
assert profile_exists("worker") is False
|
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
|
set_active_profile("worker")
|
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
|
resolve_profile_env("worker")
|
|
|
|
def test_backfill_skips_tombstoned_directory(self, profile_env):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
_delete("worker")
|
|
profile_dir.mkdir(parents=True)
|
|
(profile_env / ".hermes" / ".env").write_text(
|
|
"OPENROUTER_API_KEY=root-key\n", encoding="utf-8"
|
|
)
|
|
|
|
backfilled = backfill_profile_envs(quiet=True)
|
|
|
|
assert "worker" not in backfilled
|
|
assert not (profile_dir / ".env").exists()
|
|
|
|
def test_create_after_delete_replaces_empty_shell(self, profile_env):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
_delete("worker")
|
|
profile_dir.mkdir(parents=True)
|
|
(profile_dir / "logs").mkdir()
|
|
|
|
recreated = create_profile("worker", no_alias=True, no_skills=True)
|
|
assert recreated.is_dir()
|
|
assert "worker" in _named_homes(profile_env)
|
|
|
|
@pytest.mark.parametrize("leftover", ["config.yaml", ".env"])
|
|
def test_create_after_delete_refuses_when_identity_files_remain(
|
|
self, profile_env, leftover
|
|
):
|
|
profile_dir = create_profile("worker", no_alias=True, no_skills=True)
|
|
_delete("worker")
|
|
profile_dir.mkdir(parents=True)
|
|
leftover_path = profile_dir / leftover
|
|
leftover_path.write_text("keep-me\n", encoding="utf-8")
|
|
|
|
with pytest.raises(FileExistsError, match="already exists"):
|
|
create_profile("worker", no_alias=True, no_skills=True)
|
|
|
|
assert leftover_path.read_text(encoding="utf-8") == "keep-me\n"
|
|
assert leftover_path.exists()
|
|
|
|
|
|
class TestNamedProfileHome:
|
|
def test_logs_under_named_profile_resolve_to_profile_home(self, tmp_path):
|
|
# tmp_path acts as a real Hermes home (Docker/custom layout): it
|
|
# carries a home marker file, so profiles/ under it is canonical.
|
|
(tmp_path / "config.yaml").write_text("{}\n", encoding="utf-8")
|
|
worker = tmp_path / "profiles" / "worker"
|
|
assert named_profile_home(worker / "logs") == worker
|
|
assert named_profile_home(worker) == worker
|
|
|
|
def test_dot_hermes_layout_resolves_without_markers(self, tmp_path):
|
|
worker = tmp_path / ".hermes" / "profiles" / "worker"
|
|
assert named_profile_home(worker / "logs") == worker
|
|
assert named_profile_home(worker) == worker
|
|
|
|
def test_default_home_with_profiles_in_path_is_not_named(self, tmp_path):
|
|
default_home = tmp_path / "foo" / "profiles" / "notahome" / ".hermes"
|
|
assert named_profile_home(default_home) is None
|
|
assert named_profile_home(default_home / "logs") is None
|
|
|
|
def test_unrelated_profiles_dir_is_not_named(self, tmp_path):
|
|
# Review point 1 regression: a custom home like
|
|
# /srv/profiles/buildcache must NOT be treated as a named profile —
|
|
# its parent is not a Hermes home, so logging must keep mkdir-ing.
|
|
custom_home = tmp_path / "srv" / "profiles" / "buildcache"
|
|
assert named_profile_home(custom_home) is None
|
|
assert named_profile_home(custom_home / "logs") is None
|
|
|
|
def test_unrelated_profiles_dir_still_mkdirs(self, tmp_path):
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
|
|
custom_home = tmp_path / "srv" / "profiles" / "buildcache"
|
|
log_dir = mkdir_under_hermes_home(custom_home / "logs")
|
|
assert log_dir.is_dir()
|
|
|
|
def test_setup_logging_under_unrelated_profiles_dir_succeeds(self, tmp_path):
|
|
# End-to-end shape of the point-1 regression: setup_logging on a
|
|
# non-profile custom home whose path contains a 'profiles' segment
|
|
# must create the log dir instead of raising FileNotFoundError.
|
|
custom_home = tmp_path / "srv" / "profiles" / "buildcache"
|
|
custom_home.mkdir(parents=True)
|
|
log_dir = setup_logging(hermes_home=custom_home, force=True)
|
|
assert log_dir == custom_home / "logs"
|
|
assert log_dir.is_dir()
|
|
|
|
def test_tombstone_dir_marks_profiles_root(self, tmp_path):
|
|
# A profiles/.deleted directory is only ever created by
|
|
# `hermes profile delete` — its presence alone anchors recognition,
|
|
# so tombstones are honored even when root markers are missing.
|
|
profiles_dir = tmp_path / "opt" / "profiles"
|
|
(profiles_dir / ".deleted").mkdir(parents=True)
|
|
worker = profiles_dir / "worker"
|
|
assert named_profile_home(worker / "logs") == worker
|