fix(pm): align context-home publication and recovery scopes
Resolve dependency state and the plugin union from the active home without changing process environment. Use the existing home-root derivation for custom and named profiles. The journal validator checks the same root as the writer. Verified 71 tests passed, 8 skipped across root resolution, union, recovery, and selection. The new context-only-home regression failed before the fix.
This commit is contained in:
@@ -18,8 +18,16 @@ def install_key(project_root: Path) -> str:
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def dependency_home_root() -> Path:
|
||||
"""Scope dependency state like a process launched in the active home."""
|
||||
from hermes_constants import get_default_hermes_root, get_hermes_home_override
|
||||
|
||||
override = get_hermes_home_override()
|
||||
return get_default_hermes_root(home=override) if override else get_default_hermes_root()
|
||||
|
||||
|
||||
def installs_root() -> Path:
|
||||
return get_default_hermes_root() / "installs"
|
||||
return dependency_home_root() / "installs"
|
||||
|
||||
|
||||
def install_state_dir(project_root: Path) -> Path:
|
||||
|
||||
@@ -14,8 +14,7 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path
|
||||
|
||||
|
||||
def _lock(fd: int, *, wait: bool) -> bool:
|
||||
@@ -93,7 +92,7 @@ def recover_publication(project: Path) -> None:
|
||||
try:
|
||||
row = json.loads(data)
|
||||
config = Path(row["config"])
|
||||
if config.name != "config.yaml" or not config.resolve().is_relative_to(get_default_hermes_root().resolve()):
|
||||
if config.name != "config.yaml" or not config.resolve().is_relative_to(dependency_home_root().resolve()):
|
||||
raise ValueError("config path is outside Hermes state")
|
||||
previous = base64.b64decode(row["previous"], validate=True) if row["previous"] is not None else None
|
||||
if _digest(runtime_facts_path(project)) == row["facts_before"]:
|
||||
|
||||
@@ -143,11 +143,11 @@ def get_process_hermes_home() -> Path:
|
||||
_default_hermes_root_memo: "tuple[str, str, Path] | None" = None
|
||||
|
||||
|
||||
def get_default_hermes_root() -> Path:
|
||||
"""Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""
|
||||
def get_default_hermes_root(*, home: str | Path | None = None) -> Path:
|
||||
"""Root of an explicit home, or the process home when none is supplied."""
|
||||
global _default_hermes_root_memo
|
||||
native_home = _get_platform_default_hermes_home()
|
||||
env_home = os.environ.get("HERMES_HOME", "")
|
||||
env_home = str(home) if home is not None else os.environ.get("HERMES_HOME", "")
|
||||
memo = _default_hermes_root_memo
|
||||
if memo is not None and memo[:2] == (str(native_home), env_home):
|
||||
return memo[2]
|
||||
|
||||
@@ -15,17 +15,10 @@ from typing import Any, Optional
|
||||
|
||||
|
||||
def _profiles_root() -> Path:
|
||||
# Profile roots are derived from get_default_hermes_root() — the ONE
|
||||
# authority for "where do profiles live" (hermes_constants): it
|
||||
# returns HERMES_HOME directly for custom roots (Docker /opt/data,
|
||||
# non-default local roots) and <root> for profile-mode HERMES_HOME,
|
||||
# on both standard and custom layouts. Hardcoding Path.home()/
|
||||
# .hermes/profiles silently omits custom-root profiles — their
|
||||
# enabled dep plugins never join the union and bisect disable
|
||||
# decisions never write back to their config.
|
||||
from hermes_constants import get_default_hermes_root
|
||||
# Plugin discovery and dependency publication must use the same home root.
|
||||
from hermes_cli.runtime_paths import dependency_home_root
|
||||
|
||||
return get_default_hermes_root() / "profiles"
|
||||
return dependency_home_root() / "profiles"
|
||||
|
||||
|
||||
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
|
||||
@@ -69,9 +62,9 @@ def _all_homes() -> list[Path]:
|
||||
"""The default home + every profile home (the union's scope)."""
|
||||
homes: list[Path] = []
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_cli.runtime_paths import dependency_home_root
|
||||
|
||||
homes.append(get_default_hermes_root())
|
||||
homes.append(dependency_home_root())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
|
||||
55
tests/pm/test_runtime_context_home.py
Normal file
55
tests/pm/test_runtime_context_home.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Context-only homes use the same dependency state as their own process."""
|
||||
|
||||
from hermes_cli import runtime_paths
|
||||
from hermes_cli.plugins_admission import _config_commit
|
||||
from hermes_cli.runtime_state import recover_publication, runtime_lock
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from pm import paths, plugins_state
|
||||
|
||||
|
||||
def test_context_home_publication_recovers_from_its_own_process(tmp_path, monkeypatch):
|
||||
process_home = tmp_path / "process-home"
|
||||
context_home = tmp_path / "context-home"
|
||||
context_home.mkdir()
|
||||
project = tmp_path / "repo"
|
||||
project.mkdir()
|
||||
config = context_home / "config.yaml"
|
||||
original = b"plugins:\n enabled: [old]\n"
|
||||
config.write_bytes(original)
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
monkeypatch.setattr(paths, "repo_root", lambda: project)
|
||||
process_state = runtime_paths.install_state_dir(project)
|
||||
token = set_hermes_home_override(context_home)
|
||||
try:
|
||||
context_state = runtime_paths.install_state_dir(project)
|
||||
assert context_state != process_state
|
||||
assert plugins_state.enabled_plugins_ordered() == {
|
||||
context_home / "plugins": ["old"],
|
||||
}
|
||||
with runtime_lock(project):
|
||||
_config_commit({"new"}, set())
|
||||
assert config.read_bytes() != original
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert not process_state.exists()
|
||||
monkeypatch.setenv("HERMES_HOME", str(context_home))
|
||||
assert runtime_paths.install_state_dir(project) == context_state
|
||||
with runtime_lock(project):
|
||||
recover_publication(project)
|
||||
assert config.read_bytes() == original
|
||||
assert not (context_state / "publication.json").exists()
|
||||
|
||||
|
||||
def test_named_context_profile_shares_its_install_root(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
profile = home / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
project = tmp_path / "repo"
|
||||
state = runtime_paths.install_state_dir(project)
|
||||
token = set_hermes_home_override(profile)
|
||||
try:
|
||||
assert runtime_paths.install_state_dir(project) == state
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
Reference in New Issue
Block a user