diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 2f87216c88..99c847ce39 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -450,15 +450,37 @@ def _install_plugin_python_deps( ) return False, "dependency install declined" - from pm.workspace import enabled_member_dirs, lock_and_sync, materialize_legacy_pyproject - + # Route through pm.sync_venv — the ONE sync authority: it owns the + # lazy-off gate, the frozen-set refusal, the workspace union (with + # bisect + write-back), and the universal receipt. plugins install + # never drives uv itself (settled: it only drops the folder; the + # sync step does the rest). The plugin is NOT yet in plugins.enabled, + # so the resolve runs the WOULD-BE union: enabled members + this + # plugin, as a dry check via resolve_union (bisect decisions for + # the not-yet-enabled plugin are advisory here — the real sync + # after enable re-runs the union through sync_venv). try: - # Legacy manifest-only plugins need their generated pyproject before - # the union can see their deps. + from pm.ensure import lazy_installs_allowed + from pm.workspace import enabled_member_dirs, materialize_legacy_pyproject, resolve_union + + # Legacy manifest-only plugins need their generated pyproject + # before the union can see their deps (no-op when lazy-off). materialize_legacy_pyproject(target) - # The plugin's own dir is a member candidate already (dropped by - # _install_plugin_core); union with the other enabled plugins. - lock_and_sync(enabled_member_dirs() or [target]) + if not lazy_installs_allowed(): + return False, "lazy installs are disabled — install the deps manually" + from pm.packages import Venv + + members = enabled_member_dirs() + survivors, decisions = resolve_union( + members + ([target] if target not in members else []), + venv_dir=Venv().venv_dir(), + ) + if target not in survivors: + reason = next( + (d["reason"] for d in decisions if d["plugin"] == target.name), + "did not resolve", + ) + return False, reason except Exception as exc: return False, str(exc) return True, None diff --git a/pm/packages.py b/pm/packages.py index f73c52cf5f..b472e117d4 100644 --- a/pm/packages.py +++ b/pm/packages.py @@ -420,7 +420,7 @@ class Venv(StatePackage): # conflict, resolve_union bisects: fail-alone plugins and # mutual-conflict losers (incumbent wins) are dropped with # their resolver reasons, and the union retries. - from pm.workspace import resolve_union + from pm.workspace import record_disabled_plugins, resolve_union import logging @@ -433,6 +433,16 @@ class Venv(StatePackage): decision["plugin"], decision["reason"], ) + # Write bisect disables back to the plugins enabled config — + # `hermes plugins list` must reflect reality and re-enable + # must retry. Best-effort: a config write failure never + # breaks the sync. + try: + record_disabled_plugins(decisions) + except Exception: + logging.getLogger(__name__).warning( + "could not persist bisect disable decisions", exc_info=True + ) # Receipt: the machine-readable surface for this rebuild # (same schema/dir as update receipts; the updater embeds # these sections via pm.receipt.snapshot()). diff --git a/pm/plugins_state.py b/pm/plugins_state.py new file mode 100644 index 0000000000..a265c8befc --- /dev/null +++ b/pm/plugins_state.py @@ -0,0 +1,113 @@ +"""Which plugins are enabled, per profile — pm's read of the plugins +config (order-preserving for the incumbent-wins tiebreak). + +pm needs two things the plugins_cmd helpers don't give: EVERY profile's +enabled list (the union is per-install, cross-profile) and the list +ORDER (config order = enable recency; enabling appends). Writes go +through the same config.yaml the plugins CLI owns — pm never invents a +second authority for enabled state. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _profiles_root() -> Path: + # Profile operations are HOME-anchored by design (AGENTS.md rule 6: + # _get_profiles_root returns Path.home()/.hermes/profiles so every + # profile is visible regardless of which is active). + return Path.home() / ".hermes" / "profiles" + + +def _enabled_list_for_home(home: Path) -> list[str]: + """plugins.enabled for ONE hermes home, ORDER-PRESERVING.""" + try: + import yaml + + config_path = home / "config.yaml" + if not config_path.is_file(): + return [] + with config_path.open(encoding="utf-8-sig") as f: + config = yaml.safe_load(f) or {} + plugins_cfg = config.get("plugins") or {} + if not isinstance(plugins_cfg, dict): + return [] + enabled = plugins_cfg.get("enabled") + if not isinstance(enabled, list): + return [] + out: list[str] = [] + for name in enabled: + if isinstance(name, str) and name and name not in out: + out.append(name) + return out + except Exception: + return [] + + +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 + + homes.append(get_default_hermes_root()) + except Exception: + pass + try: + root = _profiles_root() + if root.is_dir(): + for profile in sorted(root.iterdir(), key=str): + if profile.is_dir(): + homes.append(profile) + except OSError: + pass + return homes + + +def enabled_plugins_ordered() -> dict[Path, list[str]]: + """plugins_dir → ordered enabled list, per home. Keyed by the + PLUGINS DIR (where the member dirs live), not the home itself.""" + out: dict[Path, list[str]] = {} + for home in _all_homes(): + enabled = _enabled_list_for_home(home) + if enabled: + out[home / "plugins"] = enabled + return out + + +def disable_plugins(names: list[str]) -> dict[str, list[str]]: + """Remove names from EVERY home's enabled list (a bisect decision + names the plugin, not the profile — disable where it's enabled). + Returns per-home what was removed. Writes via yaml round-trip of + the same config.yaml the plugins CLI owns.""" + removed: dict[str, list[str]] = {} + if not names: + return removed + name_set = set(names) + import yaml + + for home in _all_homes(): + config_path = home / "config.yaml" + if not config_path.is_file(): + continue + try: + with config_path.open(encoding="utf-8-sig") as f: + config = yaml.safe_load(f) or {} + plugins_cfg = config.get("plugins") + if not isinstance(plugins_cfg, dict): + continue + enabled = plugins_cfg.get("enabled") + if not isinstance(enabled, list): + continue + kept = [n for n in enabled if not (isinstance(n, str) and n in name_set)] + hit = [n for n in enabled if isinstance(n, str) and n in name_set] + if not hit: + continue + plugins_cfg["enabled"] = kept + with config_path.open("w", encoding="utf-8") as f: + yaml.safe_dump(config, f, default_flow_style=False) + removed[str(home)] = hit + except Exception: + continue + return removed diff --git a/pm/workspace.py b/pm/workspace.py index 1bb68a4cf5..b20bdb033d 100644 --- a/pm/workspace.py +++ b/pm/workspace.py @@ -113,9 +113,20 @@ def _is_member_candidate(plugin_dir: Path) -> bool: def enabled_member_dirs() -> list[Path]: - """Plugin dirs that carry python deps, machine-wide across profiles. - Per-install union: profiles share the venv, so their enabled plugins - share the resolution graph (settled).""" + """Plugin dirs that carry python deps AND are enabled, machine-wide + across profiles. Per-install union: profiles share the venv, so their + enabled plugins share the resolution graph (settled). + + ENABLED-STATE FILTER: only plugins in some profile's + ``plugins.enabled`` config list are members — a disabled plugin + never joins the sync. The result is ordered by ENABLE RECENCY + (newest-enabled LAST): profiles' enabled lists preserve config + order, and enabling appends — so the bisect's incumbent-wins + tiebreak (pop the last) disables the most-recently-enabled.""" + from pm.plugins_state import enabled_plugins_ordered + + enabled_by_root: dict[Path, set[str]] = enabled_plugins_ordered() + member_dirs: list[Path] = [] for plugins_dir in sorted(_plugin_dir_roots(), key=str): try: @@ -124,15 +135,44 @@ def enabled_member_dirs() -> list[Path]: entries = sorted(plugins_dir.iterdir(), key=str) except OSError: continue + # The profile's enabled list preserves config order (recency). + # Iterate the ENABLED names in order so members land recency- + # ordered; a name not present on disk is skipped. + enabled_names = enabled_by_root.get(plugins_dir, []) + by_name = {} for plugin_dir in entries: try: - if plugin_dir.is_dir() and _is_member_candidate(plugin_dir): + if plugin_dir.is_dir(): + by_name[plugin_dir.name] = plugin_dir + except OSError: + continue + for name in enabled_names: + plugin_dir = by_name.get(name) + if plugin_dir is None: + continue + try: + if _is_member_candidate(plugin_dir): member_dirs.append(plugin_dir) except OSError: continue return member_dirs +def record_disabled_plugins(decisions: list[dict]) -> list[str]: + """Write bisect disable decisions back to the enabled-plugins + config so `hermes plugins list` reflects reality and re-enable + retries. Returns the plugin names actually disabled.""" + if not decisions: + return [] + from pm.plugins_state import disable_plugins + + names = [d["plugin"] for d in decisions if d.get("action") == "disabled"] + if not names: + return [] + disable_plugins(names) + return names + + def materialize_legacy_pyproject(plugin_dir: Path) -> Optional[Path]: """Bridge: a plugin declaring legacy ``pip_dependencies`` / ``python_dependencies`` in plugin.yaml, with no pyproject.toml of its @@ -142,6 +182,14 @@ def materialize_legacy_pyproject(plugin_dir: Path) -> Optional[Path]: manifest = plugin_dir / "plugin.yaml" if not manifest.is_file(): return None + # Never when lazy installs are disabled (settled): materializing the + # generated pyproject would make the dir a workspace-member candidate + # and then hard-fail every sealed/lazy-off sync. The frozen-bundle + # posture keeps the dir untouched. + from pm.ensure import lazy_installs_allowed + + if not lazy_installs_allowed(): + return None generated = plugin_dir / "pyproject.toml" if generated.is_file(): # A USER-owned pyproject is the modern plugin shape — nothing to diff --git a/tests/hermes_cli/test_plugins_cmd_deps_flow.py b/tests/hermes_cli/test_plugins_cmd_deps_flow.py index 16ab3598ed..f9adad83e6 100644 --- a/tests/hermes_cli/test_plugins_cmd_deps_flow.py +++ b/tests/hermes_cli/test_plugins_cmd_deps_flow.py @@ -26,6 +26,22 @@ class _Console: self.lines.append(str(arg)) +@pytest.fixture +def resolve_env(monkeypatch): + """Lazy installs on + no pre-existing enabled members, so the resolve + runs the would-be union with just this plugin.""" + import importlib + import sys + + if "pm.ensure" not in sys.modules: + importlib.import_module("pm.ensure") + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + monkeypatch.setattr( + "pm.workspace.enabled_member_dirs", lambda: [] + ) + + def _legacy_plugin(target: Path) -> None: target.mkdir(parents=True, exist_ok=True) (target / "plugin.yaml").write_text( @@ -44,7 +60,7 @@ def test_no_deps_short_circuits_true(tmp_path): assert ok is True and reason is None -def test_noninteractive_skips_install(tmp_path, monkeypatch): +def test_noninteractive_skips_install(tmp_path, monkeypatch, resolve_env): plug = tmp_path / "dep-plug" _legacy_plugin(plug) monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: False, raising=False) @@ -62,7 +78,7 @@ def test_noninteractive_skips_install(tmp_path, monkeypatch): assert not called # nothing installed, nothing enabled -def test_decline_skips_install(tmp_path, monkeypatch): +def test_decline_skips_install(tmp_path, monkeypatch, resolve_env): plug = tmp_path / "dep-plug" _legacy_plugin(plug) monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) @@ -82,7 +98,7 @@ def test_decline_skips_install(tmp_path, monkeypatch): assert not called -def test_conflict_reports_reason_not_crash(tmp_path, monkeypatch): +def test_conflict_reports_reason_not_crash(tmp_path, monkeypatch, resolve_env): plug = tmp_path / "dep-plug" _legacy_plugin(plug) monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) @@ -105,7 +121,7 @@ def test_conflict_reports_reason_not_crash(tmp_path, monkeypatch): assert "unsatisfiable" in reason -def test_success_installs_and_legacy_bridge_materializes(tmp_path, monkeypatch): +def test_success_installs_and_legacy_bridge_materializes(tmp_path, monkeypatch, resolve_env): plug = tmp_path / "dep-plug" _legacy_plugin(plug) monkeypatch.setattr(pc.sys.stdin, "isatty", lambda: True, raising=False) diff --git a/tests/pm/test_plugins_state.py b/tests/pm/test_plugins_state.py new file mode 100644 index 0000000000..57e38cc236 --- /dev/null +++ b/tests/pm/test_plugins_state.py @@ -0,0 +1,87 @@ +"""pm.plugins_state: order-preserving enabled reads + disable write-back. + +The union is cross-profile and recency-ordered; the bisect writes its +disable decisions back through the same config.yaml the plugins CLI +owns. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import pm.plugins_state as pstate + + +@pytest.fixture +def homes(tmp_path, monkeypatch): + """Default home + one profile, each with a config.yaml.""" + default_home = tmp_path / "default-home" + profile_home = tmp_path / "profiles" / "work" + default_home.mkdir(parents=True) + profile_home.mkdir(parents=True) + + import hermes_constants + + monkeypatch.setattr( + hermes_constants, "get_default_hermes_root", lambda: default_home + ) + monkeypatch.setattr(pstate, "_profiles_root", lambda: tmp_path / "profiles") + return default_home, profile_home + + +def _write_config(home: Path, enabled: list) -> None: + import yaml + + config = {"plugins": {"enabled": enabled}} if enabled else {"plugins": {}} + with (home / "config.yaml").open("w", encoding="utf-8") as f: + yaml.safe_dump(config, f) + + +def test_enabled_plugins_ordered_reads_all_homes(homes): + default_home, profile_home = homes + _write_config(default_home, ["a-plug", "b-plug"]) + _write_config(profile_home, ["c-plug"]) + + by_root = pstate.enabled_plugins_ordered() + assert by_root.get(default_home / "plugins") == ["a-plug", "b-plug"] + assert by_root.get(profile_home / "plugins") == ["c-plug"] + + +def test_enabled_list_preserves_config_order(homes): + default_home, _ = homes + # NOT alphabetical: recency order must survive the read + _write_config(default_home, ["z-first-enabled", "a-second"]) + by_root = pstate.enabled_plugins_ordered() + assert by_root[default_home / "plugins"] == ["z-first-enabled", "a-second"] + + +def test_disable_plugins_removes_across_homes(homes): + default_home, profile_home = homes + _write_config(default_home, ["bad-plug", "keep-plug"]) + _write_config(profile_home, ["bad-plug", "other"]) + + removed = pstate.disable_plugins(["bad-plug"]) + assert removed[str(default_home)] == ["bad-plug"] + assert removed[str(profile_home)] == ["bad-plug"] + + by_root = pstate.enabled_plugins_ordered() + assert by_root[default_home / "plugins"] == ["keep-plug"] + assert by_root[profile_home / "plugins"] == ["other"] + + +def test_disable_plugins_noop_when_not_enabled(homes): + default_home, _ = homes + _write_config(default_home, ["keep-plug"]) + removed = pstate.disable_plugins(["not-there"]) + assert removed == {} + # config untouched + by_root = pstate.enabled_plugins_ordered() + assert by_root[default_home / "plugins"] == ["keep-plug"] + + +def test_enabled_read_survives_garbage_config(homes): + default_home, _ = homes + (default_home / "config.yaml").write_text("{ not yaml", encoding="utf-8") + assert pstate.enabled_plugins_ordered() == {} diff --git a/tests/pm/test_workspace.py b/tests/pm/test_workspace.py index 23b6647248..2eb3201e5e 100644 --- a/tests/pm/test_workspace.py +++ b/tests/pm/test_workspace.py @@ -148,9 +148,8 @@ def test_member_stamp_hash_changes_with_plugin_set(layout): assert stamp_a == stamp_b -def test_enabled_member_dirs_finds_pyproject_and_legacy_plugins(tmp_path, monkeypatch): - home = tmp_path / "home" - plugins = home / "plugins" +def test_enabled_member_dirs_finds_enabled_dep_plugins(tmp_path, monkeypatch): + plugins = tmp_path / "plugins" plugins.mkdir(parents=True) # modern plugin: pyproject.toml @@ -166,21 +165,69 @@ def test_enabled_member_dirs_finds_pyproject_and_legacy_plugins(tmp_path, monkey encoding="utf-8", ) - # dep-less plugin: neither — not a member + # dep-less plugin: neither — not a member even when enabled plain = plugins / "plain-plug" plain.mkdir() (plain / "plugin.yaml").write_text("name: plain-plug\n", encoding="utf-8") - # not a plugin dir at all - (plugins / "stray.txt").write_text("x", encoding="utf-8") + # dep-carrying but NOT-ENABLED plugin — must not join the union + orphan = plugins / "orphan-plug" + orphan.mkdir() + (orphan / "pyproject.toml").write_text("[project]\n", encoding="utf-8") monkeypatch.setattr(ws, "_plugin_dir_roots", lambda: {plugins}) + # enabled order = enable recency (legacy enabled first/older, modern + # newest LAST) — order must carry through for the bisect tiebreak. + monkeypatch.setattr( + "pm.plugins_state.enabled_plugins_ordered", + lambda: {plugins: ["legacy-plug", "modern-plug", "plain-plug"]}, + ) found = ws.enabled_member_dirs() - names = {p.name for p in found} - assert names == {"modern-plug", "legacy-plug"} + names = [p.name for p in found] + assert names == ["legacy-plug", "modern-plug"] + assert "orphan-plug" not in names -def test_materialize_legacy_pyproject_from_pip_dependencies(tmp_path): +def test_enabled_member_dirs_empty_when_nothing_enabled(tmp_path, monkeypatch): + plugins = tmp_path / "plugins" + plugins.mkdir(parents=True) + member = plugins / "member" + member.mkdir() + (member / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + + monkeypatch.setattr(ws, "_plugin_dir_roots", lambda: {plugins}) + monkeypatch.setattr( + "pm.plugins_state.enabled_plugins_ordered", lambda: {} + ) + assert ws.enabled_member_dirs() == [] + + +def test_record_disabled_plugins_writes_back(monkeypatch): + calls = [] + + def fake_disable(names): + calls.append(names) + + monkeypatch.setattr("pm.plugins_state.disable_plugins", fake_disable) + removed = ws.record_disabled_plugins( + [ + {"plugin": "bad", "action": "disabled", "reason": "conflict"}, + {"plugin": "good", "action": "kept", "reason": ""}, + ] + ) + assert removed == ["bad"] + assert calls == [["bad"]] + + +@pytest.fixture +def lazy_on(monkeypatch): + import sys + + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True) + + +def test_materialize_legacy_pyproject_from_pip_dependencies(tmp_path, lazy_on): plug = tmp_path / "legacy-plug" plug.mkdir() (plug / "plugin.yaml").write_text( @@ -267,5 +314,24 @@ def test_enabled_member_dirs_survives_unreadable_roots(tmp_path, monkeypatch): raise OSError("dangling junction") monkeypatch.setattr(ws, "_plugin_dir_roots", lambda: {good, _Broken()}) + monkeypatch.setattr( + "pm.plugins_state.enabled_plugins_ordered", + lambda: {good: ["member"]}, + ) found = ws.enabled_member_dirs() assert [p.name for p in found] == ["member"] + + +def test_materialize_never_when_lazy_off(tmp_path, monkeypatch): + import sys + + ensure_mod = sys.modules["pm.ensure"] + monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False) + plug = tmp_path / "legacy-plug" + plug.mkdir() + (plug / "plugin.yaml").write_text( + "name: legacy-plug\npip_dependencies:\n - \"requests>=2\"\n", + encoding="utf-8", + ) + assert ws.materialize_legacy_pyproject(plug) is None + assert not (plug / "pyproject.toml").exists()