Keep upstream's reviewed catalog as the only plugin name index. Catalog pins and custom update sources share staged PM validation. Publish code and dependencies with recovery after process death. Reject a concurrent enablement change before publishing disabled code. Use the manifest loader's supported version in the installer. Keep probe cooldowns for timeouts, not TLS failures that a CA change fixes. Preserve the backup, uninstall, browser and memory-provider repairs. Verified with the canonical runner on native Windows ARM64, real Git repositories, local TLS endpoints and UV dependency generations. Desktop catalog tests and both TypeScript checks pass. The full suite and native release builds were not run. No remote push.
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""pm.workspace: the generated uv-workspace root for plugin deps.
|
|
|
|
The workspace root is a pm-GENERATED project (never the committed
|
|
pyproject.toml — sealed installs are read-only and member lists are
|
|
machine-specific). Its pyproject = core's pyproject verbatim +
|
|
``[tool.uv.workspace] members`` pointing at each enabled plugin dir via
|
|
relative ``../``-escaping paths (proven to resolve). ``uv lock`` unions
|
|
core + plugin deps into ONE lock; conflict = loud refusal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pm.workspace as ws
|
|
|
|
|
|
@pytest.fixture
|
|
def layout(tmp_path, monkeypatch):
|
|
"""A fake install: core repo with pyproject, plugin dirs, store."""
|
|
core = tmp_path / "core"
|
|
core.mkdir()
|
|
(core / "pyproject.toml").write_text(
|
|
"[project]\n"
|
|
'name = "hermes-agent"\n'
|
|
'version = "0.1.0"\n'
|
|
'requires-python = ">=3.11"\n'
|
|
'dependencies = ["httpx==0.28.1"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
plugins = tmp_path / "home" / "plugins"
|
|
plug_a = plugins / "plug-a"
|
|
plug_a.mkdir(parents=True)
|
|
(plug_a / "plugin.yaml").write_text("name: plug-a\n", encoding="utf-8")
|
|
(plug_a / "pyproject.toml").write_text(
|
|
"[project]\nname = \"plug-a\"\nversion = \"0.1.0\"\n"
|
|
'requires-python = ">=3.11"\ndependencies = ["rich==13.9.4"]\n',
|
|
encoding="utf-8",
|
|
)
|
|
store = tmp_path / "store"
|
|
store.mkdir()
|
|
monkeypatch.setattr(ws.paths, "repo_root", lambda: core)
|
|
monkeypatch.setattr(ws.paths, "store_root", lambda: store)
|
|
return tmp_path, core, plug_a, store
|
|
|
|
|
|
def test_workspace_root_is_per_install_not_in_the_store(layout):
|
|
from hermes_cli.runtime_paths import install_state_dir
|
|
_, core, _, store = layout
|
|
assert ws.workspace_root() == install_state_dir(core) / ".pm-workspace"
|
|
assert not ws.workspace_root().is_relative_to(store)
|
|
|
|
|
|
def test_build_writes_core_pyproject_verbatim(layout):
|
|
_, core, plug_a, _ = layout
|
|
root = ws.build_root([plug_a])
|
|
text = (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
core_text = (core / "pyproject.toml").read_text(encoding="utf-8")
|
|
# core's project table is carried verbatim (name, deps, requires-python)
|
|
assert 'name = "hermes-agent"' in text
|
|
assert 'dependencies = ["httpx==0.28.1"]' in text
|
|
assert 'requires-python = ">=3.11"' in text
|
|
# nothing else was invented
|
|
for line in core_text.strip().splitlines():
|
|
assert line in text
|
|
|
|
|
|
def test_members_keep_their_source_with_the_generation(layout):
|
|
import tomllib
|
|
|
|
_, _, plug_a, _ = layout
|
|
root = ws.build_root([plug_a])
|
|
document = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
[relative] = document["tool"]["uv"]["workspace"]["members"]
|
|
copied = root / relative / "pyproject.toml"
|
|
assert copied.resolve().is_relative_to(root.resolve())
|
|
before = copied.read_bytes()
|
|
assert before == (plug_a / "pyproject.toml").read_bytes()
|
|
(plug_a / "pyproject.toml").write_bytes(b"changed after publication")
|
|
assert copied.read_bytes() == before
|
|
|
|
|
|
def test_build_is_idempotent(layout):
|
|
_, _, plug_a, _ = layout
|
|
ws.build_root([plug_a])
|
|
first = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8")
|
|
ws.build_root([plug_a])
|
|
second = (ws.workspace_root() / "pyproject.toml").read_text(encoding="utf-8")
|
|
assert first == second
|
|
|
|
|
|
def test_zero_plugins_still_builds_a_root_with_no_members(layout):
|
|
_, _, _, _ = layout
|
|
root = ws.build_root([])
|
|
text = (root / "pyproject.toml").read_text(encoding="utf-8")
|
|
assert 'name = "hermes-agent"' in text
|
|
assert "[tool.uv.workspace]" not in text or "members = []" in text
|
|
|
|
|
|
def test_member_stamp_hash_changes_with_plugin_set(layout):
|
|
_, _, plug_a, _ = layout
|
|
stamp_empty = ws.members_stamp([])
|
|
stamp_a = ws.members_stamp([plug_a])
|
|
stamp_b = ws.members_stamp([plug_a, plug_a]) # dedupes to same set
|
|
assert stamp_empty != stamp_a
|
|
assert stamp_a == stamp_b
|
|
|
|
|
|
def test_member_stamp_changes_when_pyproject_content_changes(tmp_path):
|
|
"""Task 4 contract: a pulled plugin with changed pins must move the
|
|
stamp — path-only hashing left dep bumps invisible to the venv sync."""
|
|
plug = tmp_path / "plug"
|
|
plug.mkdir()
|
|
(plug / "pyproject.toml").write_text(
|
|
'dependencies = ["pkg==1.0.0"]\n', encoding="utf-8"
|
|
)
|
|
before = ws.members_stamp([plug])
|
|
# the plugin update: same dir, new pins
|
|
(plug / "pyproject.toml").write_text(
|
|
'dependencies = ["pkg==2.0.0"]\n', encoding="utf-8"
|
|
)
|
|
after = ws.members_stamp([plug])
|
|
assert before != after
|
|
# no pyproject at all: still hashable (the dir identity carries it)
|
|
bare = tmp_path / "bare"
|
|
bare.mkdir()
|
|
assert ws.members_stamp([bare]) != before
|
|
|
|
|
|
def test_member_stamp_missing_pyproject_does_not_crash(tmp_path):
|
|
"""A member dir whose pyproject vanished mid-scan hashes on path only."""
|
|
plug = tmp_path / "ghost"
|
|
plug.mkdir()
|
|
(plug / "pyproject.toml").write_text("x\n", encoding="utf-8")
|
|
first = ws.members_stamp([plug])
|
|
(plug / "pyproject.toml").unlink()
|
|
second = ws.members_stamp([plug])
|
|
assert first != second # content term dropped out, stamp moved
|
|
|
|
|
|
def test_enabled_member_dirs_finds_enabled_dep_plugins(tmp_path, monkeypatch):
|
|
plugins = tmp_path / "plugins"
|
|
plugins.mkdir(parents=True)
|
|
|
|
# modern plugin: pyproject.toml
|
|
modern = plugins / "modern-plug"
|
|
modern.mkdir()
|
|
(modern / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
|
|
# legacy plugin: pip_dependencies in plugin.yaml, no pyproject
|
|
legacy = plugins / "legacy-plug"
|
|
legacy.mkdir()
|
|
(legacy / "plugin.yaml").write_text(
|
|
"name: legacy-plug\npip_dependencies:\n - \"requests>=2\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# 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")
|
|
|
|
# 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")
|
|
|
|
# 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 == ["legacy-plug", "modern-plug"]
|
|
assert "orphan-plug" not in names
|
|
|
|
|
|
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(
|
|
"pm.plugins_state.enabled_plugins_ordered", lambda: {}
|
|
)
|
|
assert ws.enabled_member_dirs() == []
|
|
|
|
|
|
def test_scan_plugin_classifies_dep_surfaces(tmp_path):
|
|
full = tmp_path / "full-plug"
|
|
full.mkdir()
|
|
for name in ("pyproject.toml", "package.json", "packages.py", "plugin.yaml"):
|
|
(full / name).write_text("x\n", encoding="utf-8")
|
|
scan = ws.scan_plugin(full)
|
|
assert scan["pyproject"] and scan["package_json"] and scan["packages_py"]
|
|
assert not scan["legacy_deps"]
|
|
|
|
legacy = tmp_path / "legacy-plug"
|
|
legacy.mkdir()
|
|
(legacy / "plugin.yaml").write_text(
|
|
"name: legacy\npip_dependencies:\n - \"x>=1\"\n", encoding="utf-8"
|
|
)
|
|
scan = ws.scan_plugin(legacy)
|
|
assert scan["legacy_deps"] and not scan["pyproject"]
|
|
|
|
|
|
def test_enabled_member_dirs_ignores_non_profile_entries(tmp_path, monkeypatch):
|
|
import pm.plugins_state as pstate
|
|
|
|
home = tmp_path / "home"
|
|
profiles = home / "profiles"
|
|
profiles.mkdir(parents=True)
|
|
member = profiles / "work" / "plugins" / "member"
|
|
member.mkdir(parents=True)
|
|
(member / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
(profiles / "work" / "config.yaml").write_text(
|
|
"plugins:\n enabled: [member]\n", encoding="utf-8",
|
|
)
|
|
(profiles / "README.txt").write_text("not a profile", encoding="utf-8")
|
|
monkeypatch.setattr("hermes_cli.runtime_paths.dependency_home_root", lambda: home)
|
|
|
|
assert pstate._all_homes() == [home, profiles / "work"]
|
|
assert ws.enabled_member_dirs() == [member]
|
|
|
|
|
|
# --- classified failures + staging surface (FINAL-RUNTIME-CONTRACT) ---
|
|
|
|
|
|
def test_classify_resolver_conflict_is_resolutionconflict():
|
|
from pm.package import InstallError
|
|
from pm.workspace import ResolutionConflict, classify_uv_failure
|
|
|
|
err = classify_uv_failure(
|
|
"lock", 1,
|
|
" x No solution found for `hermes-agent>=0.1.0` because only the "
|
|
"following versions are available:\n",
|
|
)
|
|
assert isinstance(err, ResolutionConflict)
|
|
assert isinstance(err, InstallError)
|
|
assert "no solution found" in str(err).lower()
|
|
|
|
|
|
def test_classify_network_failure_stays_generic():
|
|
from pm.workspace import ResolutionConflict, classify_uv_failure
|
|
|
|
err = classify_uv_failure("lock", 1, "error: Failed to fetch https://pypi.org (timed out)")
|
|
assert not isinstance(err, ResolutionConflict)
|
|
|
|
|
|
def test_sync_failure_is_never_a_conflict(tmp_path, monkeypatch):
|
|
"""--frozen: the lock already resolved, so a sync failure (download,
|
|
build, tooling) must stay generic — it must not disable plugins."""
|
|
from pm.package import InstallError
|
|
from pm.workspace import ResolutionConflict
|
|
|
|
class FakeProc:
|
|
returncode = 1
|
|
stderr = "error: Failed to download wheel (connection reset)"
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (Path("/x/ws"), False))
|
|
monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv",
|
|
lambda **kwargs: ("uv", {"UV_CACHE_DIR": "/x/cache", "UV_PYTHON": "pm-python"}))
|
|
|
|
captured = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(ws.subprocess, "run", fake_run)
|
|
with pytest.raises(InstallError) as excinfo:
|
|
ws.lock_and_sync([], [], venv_dir=tmp_path / "candidate")
|
|
assert not isinstance(excinfo.value, ResolutionConflict)
|
|
|
|
|
|
def test_staging_root_and_env_are_honored_without_live_mutation(monkeypatch, tmp_path):
|
|
"""lock_and_sync must resolve into the PARENT-SUPPLIED staging root +
|
|
venv, pass a COPY of the environment (never mutate the live one), and
|
|
leave the default generated root untouched."""
|
|
staging = tmp_path / "staging-ws"
|
|
staging.mkdir()
|
|
|
|
seen = {}
|
|
|
|
class FakeProc:
|
|
returncode = 0
|
|
stderr = ""
|
|
stdout = ""
|
|
|
|
def fake_run(cmd, cwd=None, env=None, **kwargs):
|
|
seen["cwd"] = cwd
|
|
seen["env"] = env
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(ws, "_generate_pyproject", lambda *a, **k: (staging, False))
|
|
monkeypatch.setattr(
|
|
importlib.import_module("pm.ensure"), "uv",
|
|
lambda **kwargs: ("uv", {**(kwargs.get("base_env") or {}),
|
|
"UV_CACHE_DIR": "/x/cache", "UV_PYTHON": "pm-python"}),
|
|
)
|
|
monkeypatch.setattr(ws.subprocess, "run", fake_run)
|
|
|
|
live_key = "PM_WORKSPACE_TEST_SENTINEL"
|
|
os.environ[live_key] = "live"
|
|
try:
|
|
ws.lock_and_sync(
|
|
[], [], venv_dir=tmp_path / "staging-venv", root=staging,
|
|
env={"PATH": "/staged/bin", live_key: "staged"},
|
|
)
|
|
|
|
assert Path(seen["cwd"]) == staging
|
|
assert seen["env"][live_key] == "staged" # staged env wins
|
|
assert seen["env"]["UV_CACHE_DIR"] == "/x/cache" # uv_env layered on top
|
|
assert seen["env"]["UV_PROJECT_ENVIRONMENT"] == str(tmp_path / "staging-venv")
|
|
assert seen["env"]["UV_PYTHON"] == "pm-python"
|
|
assert os.environ[live_key] == "live" # live env untouched
|
|
finally:
|
|
del os.environ[live_key]
|
|
|
|
|
|
def test_changed_root_seeds_from_committed_lock_unchanged_keeps_extended(
|
|
layout, monkeypatch, tmp_path
|
|
):
|
|
"""Seed the CURRENT resolution without writing shipped bytes: a fresh
|
|
root seeds from the committed lock; an existing root seeds from its
|
|
own extended lock; an explicit parent-supplied seed_lock wins."""
|
|
_, core, plug_a, store = layout
|
|
(core / "uv.lock").write_bytes(b"committed-bytes\n")
|
|
|
|
class FakeProc:
|
|
returncode = 0
|
|
stderr = ""
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr(importlib.import_module("pm.ensure"), "uv",
|
|
lambda **kwargs: ("uv", {"UV_PYTHON": "pm-python"}))
|
|
monkeypatch.setattr(ws.subprocess, "run", lambda cmd, **k: FakeProc())
|
|
|
|
root = ws.workspace_root()
|
|
venv = tmp_path / "venv"
|
|
|
|
# First sync: fresh root -> seeded from the COMMITTED lock.
|
|
ws.lock_and_sync([plug_a], [], venv_dir=venv)
|
|
assert (root / "uv.lock").read_bytes() == b"committed-bytes\n"
|
|
|
|
# Changed surface (new member), root's own EXTENDED lock present ->
|
|
# the current extended resolution is the seed, not the committed one.
|
|
(root / "uv.lock").write_bytes(b"extended-bytes\n")
|
|
plug_b = layout[0] / "home" / "plugins" / "plug-b"
|
|
plug_b.mkdir(parents=True)
|
|
(plug_b / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
ws.lock_and_sync([plug_a, plug_b], [], venv_dir=venv)
|
|
assert (root / "uv.lock").read_bytes() == b"extended-bytes\n"
|
|
|
|
# Parent-supplied seed_lock wins over both.
|
|
other = tmp_path / "parent-extended.lock"
|
|
other.write_bytes(b"parent-bytes\n")
|
|
plug_c = layout[0] / "home" / "plugins" / "plug-c"
|
|
plug_c.mkdir(parents=True)
|
|
(plug_c / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
|
ws.lock_and_sync([plug_a, plug_b, plug_c], [], venv_dir=venv, seed_lock=other)
|
|
assert (root / "uv.lock").read_bytes() == b"parent-bytes\n"
|
|
|
|
# Shipped core lock untouched throughout.
|
|
assert (core / "uv.lock").read_bytes() == b"committed-bytes\n"
|