Files
hermes-agent/pm/workspace.py
ethernet d5922fb698 fix(pm): derive profile roots from get_default_hermes_root — custom HERMES_HOME joins the union
Profile-discovery gap (found in deployment review): with a custom
HERMES_HOME root (Docker /opt/data, non-default local roots), profiles
live under <HERMES_HOME>/profiles/<name>, but the new pm code scanned
Path.home()/.hermes/profiles in two places (plugins_state._profiles_root,
workspace._plugin_dir_roots). Result: an enabled dep plugin in a
custom-root profile was silently omitted from the union, and bisect
disable decisions never wrote back to that profile's config.

Fix: both sites derive from get_default_hermes_root() — the ONE
authority (hermes_constants), which already handles every layout:
custom HERMES_HOME → that root directly, profile-mode
<root>/profiles/<name> → <root>, standard ~/.hermes unchanged. The
hardcoded restatement (with a comment even citing the HOME-anchor rule
as justification — the authority IS home-anchored, it just also
handles custom roots) is gone.

tests/pm/test_custom_root_union.py: 4 e2e tests through the REAL
authority (no path mocks on the derivation itself): Docker-shape root
(union discovery + disable write-back to the profile's config),
standard layout (unchanged), profile-mode HERMES_HOME (sibling
profiles visible). tests/pm: 194 passed, 0 failed.
2026-09-03 10:55:26 -04:00

399 lines
14 KiB
Python

"""The generated uv-workspace root that unions plugin deps into the venv.
Design (settled 2026-09-02, .hermes/plans/2026-09-02_164500-plugin-deps-
workspace-union.md):
- The workspace root is pm-GENERATED, never the committed pyproject.toml.
Sealed installs are read-only, and the member list is machine-specific
(which plugins the user enabled). Generated root lives beside the byte
store: ``<store_root()>/.pm-workspace`` — per-install, writable on every
install kind.
- Its pyproject = core's pyproject verbatim + a ``[tool.uv.workspace]``
members block of relative ``../``-escaping paths to each enabled plugin
dir. Relative members can escape the workspace root (probed live).
- ``uv lock`` resolves core + plugin deps as ONE graph: existing core pins
are preserved (a plugin's range spec does not move them) and a conflict
fails loudly, so the plugin simply does not install.
- ``uv sync --frozen --extra ...`` at the root installs the union into the
project venv; ``UV_PROJECT_ENVIRONMENT`` pins WHICH venv that is.
- Plugin-member extras are banned/ignored for now (settled): uv selects
only ROOT extras, and the root mirrors core's extras.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import Optional
from pm import paths
WORKSPACE_DIRNAME = ".pm-workspace"
def workspace_root() -> Path:
"""The generated workspace root — per-install, beside the byte store."""
return paths.store_root() / WORKSPACE_DIRNAME
def _member_rel(root: Path, plugin_dir: Path) -> str:
"""Workspace member string: relative path from the root to the plugin
dir, always escaping the root (``../…``) — probed to resolve."""
return os.path.relpath(plugin_dir.resolve(), root.resolve()).replace("\\", "/")
def members_stamp(plugin_dirs: list[Path]) -> str:
"""Content hash of the member SET — order-independent, so venv stamps
compare the set of unioned plugins, not the discovery order."""
resolved = sorted({str(p.resolve()) for p in plugin_dirs})
h = hashlib.sha256()
for entry in resolved:
h.update(entry.encode("utf-8"))
h.update(b"\0")
return h.hexdigest()
def build_root(plugin_dirs: list[Path]) -> Path:
"""(Re)generate the workspace root's pyproject.toml from core's
pyproject + the enabled plugin members. Idempotent — same inputs,
same bytes."""
root = workspace_root()
root.mkdir(parents=True, exist_ok=True)
core_pyproject = paths.repo_root() / "pyproject.toml"
core_text = core_pyproject.read_text(encoding="utf-8-sig")
members = [_member_rel(root, Path(p)) for p in {str(Path(p).resolve()) for p in plugin_dirs}]
lines = [core_text.rstrip("\n")]
if members:
lines.append("")
lines.append("[tool.uv.workspace]")
lines.append("members = [" + ", ".join(f'"{m}"' for m in sorted(members)) + "]")
(root / "pyproject.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
return root
def _plugin_dir_roots() -> set[Path]:
"""All profile plugin dirs + the default home's, machine-wide.
Profile roots derive from get_default_hermes_root() — the ONE
authority (custom HERMES_HOME → that root directly; profile-mode
→ its parent), so Docker/custom-root profiles join the union the
same as standard ones."""
roots: set[Path] = set()
try:
from hermes_constants import get_default_hermes_root
profiles_root = get_default_hermes_root() / "profiles"
if profiles_root.is_dir():
for profile in profiles_root.iterdir():
if profile.is_dir():
roots.add(profile / "plugins")
except OSError:
pass
try:
from hermes_constants import get_default_hermes_root
roots.add(get_default_hermes_root() / "plugins")
except Exception:
pass
return roots
def _is_member_candidate(plugin_dir: Path) -> bool:
"""A plugin dir is a workspace-member candidate when it declares python
deps: a pyproject.toml (modern), or legacy pip_dependencies/
python_dependencies in plugin.yaml (the bridge materializes those)."""
try:
if (plugin_dir / "pyproject.toml").is_file():
return True
manifest = plugin_dir / "plugin.yaml"
if manifest.is_file():
text = manifest.read_text(encoding="utf-8-sig")
return "pip_dependencies" in text or "python_dependencies" in text
except OSError:
return False
return False
def enabled_member_dirs() -> list[Path]:
"""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:
if not plugins_dir.is_dir():
continue
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():
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
own, gets one MATERIALIZED — the same specs, same workspace-union
install path. Never when lazy installs are off (the sync refuses
separately); idempotent (regenerating is a no-op when specs match)."""
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
# bridge. One WE generated earlier is still bridgeable (spec
# changes must rewrite it); the GENERATED header marks ours.
try:
existing = generated.read_text(encoding="utf-8-sig")
except OSError:
return None
if "GENERATED by pm" not in existing:
return None
try:
import re
text = manifest.read_text(encoding="utf-8-sig")
except OSError:
return None
specs: list[str] = []
for key in ("pip_dependencies", "python_dependencies"):
block = re.search(
rf"^\s*{key}:\s*\n((?:[ \t]+- [^\n]+\n?)*)", text, re.MULTILINE
)
if not block:
continue
for line in block.group(1).splitlines():
item = line.strip()
if item.startswith("- "):
spec = item[2:].strip().strip("'\"")
if spec:
specs.append(spec)
if not specs:
return None
name = plugin_dir.name
body = (
"# GENERATED by pm from plugin.yaml pip_dependencies — the legacy\n"
"# bridge (settled 2026-09-02). Migrate to a real pyproject.toml +\n"
"# uv.lock; this file is rewritten when the manifest changes.\n"
"[project]\n"
f'name = "{name}"\n'
'version = "0.0.0"\n'
'requires-python = ">=3.11"\n'
f'dependencies = [{", ".join(repr(s) for s in specs)}]\n'
)
if generated.is_file():
try:
if generated.read_text(encoding="utf-8-sig") == body:
return generated # already current
except OSError:
pass
generated.write_text(body, encoding="utf-8")
return generated
def scan_plugin(plugin_dir: Path) -> dict:
"""Auto-pickup scan of one plugin dir: which dep surfaces it declares.
Priority (settled): pyproject (python), package.json (node sidecar),
packages.py (pm store binaries), legacy manifest deps (bridge)."""
found: dict = {
"pyproject": (plugin_dir / "pyproject.toml").is_file(),
"package_json": (plugin_dir / "package.json").is_file(),
"packages_py": (plugin_dir / "packages.py").is_file(),
"legacy_deps": _is_member_candidate(plugin_dir)
and not (plugin_dir / "pyproject.toml").is_file(),
"dir": plugin_dir,
}
return found
def lock_and_sync(
plugin_dirs: list[Path],
extras: Optional[list[str]] = None,
*,
venv_dir: Optional[Path] = None,
) -> None:
"""Build the root, then `uv lock` + `uv sync --frozen --extra ...`.
Raises InstallError on any failure (the caller surfaces the resolver's
message — that IS the plugin-conflict UX). ``venv_dir`` pins
UV_PROJECT_ENVIRONMENT; default is the core project venv.
"""
from pm.package import InstallError
from pm.packages import uv_env
root = build_root(plugin_dirs)
if venv_dir is None:
venv_dir = _default_venv_dir()
uv_bin = _uv_binary()
if uv_bin is None:
raise InstallError("venv", "uv is not installed (pm ensure uv)")
env = uv_env()
env["UV_PROJECT_ENVIRONMENT"] = str(venv_dir)
import subprocess
lock = subprocess.run(
[uv_bin, "lock"], cwd=str(root), env=env, capture_output=True, text=True
)
if lock.returncode != 0:
raise InstallError("venv", f"uv lock exited {lock.returncode}: {lock.stderr[-600:]}")
# --all-packages is REQUIRED: plain `uv sync --frozen` installs only the
# ROOT project's deps — workspace-member deps are locked by `uv lock`
# but silently never reach site-packages (probed live 2026-09-03: a
# member's pyfiglet stayed absent from site-packages under plain
# --frozen, present under --all-packages). The union's whole contract
# is that plugin deps ride the venv; without this flag every member
# install is a silent no-op.
cmd = [uv_bin, "sync", "--frozen", "--all-packages"]
for extra in sorted(set(extras or [])):
cmd += ["--extra", extra]
sync = subprocess.run(cmd, cwd=str(root), env=env, capture_output=True, text=True)
if sync.returncode != 0:
raise InstallError(
"venv", f"uv sync exited {sync.returncode}: {sync.stderr[-600:]}"
)
def resolve_union(
plugin_dirs: list[Path],
extras: Optional[list[str]] = None,
*,
venv_dir: Optional[Path] = None,
) -> tuple[list[Path], list[dict]]:
"""Try the full union; on failure, bisect the member set.
Returns (surviving_members, decisions). Every decision is
{plugin, action: kept|disabled, reason}. Fail-alone plugins are
disabled with the resolver's message; mutually-conflicting plugins
are resolved incumbent-wins (the most-recently-enabled member of a
conflicting pair is the one disabled — order = plugin_dirs list,
LAST wins the tiebreak because the caller passes
newest-last). Retries the union until it resolves or every member
is disabled; never raises for a resolution failure (a uv binary
absence still raises InstallError)."""
from pm.package import InstallError
survivors = list(plugin_dirs)
decisions: list[dict] = []
def _try(members: list[Path]) -> Optional[str]:
"""None on success, else the failure reason."""
try:
lock_and_sync(members, extras, venv_dir=venv_dir)
return None
except InstallError as exc:
return str(exc)
reason = _try(survivors)
if reason is None:
return survivors, decisions
# Phase 1: each member alone against core. Fail-alone = disabled.
alone_ok: dict[Path, Optional[str]] = {}
for member in list(survivors):
fail = _try([member])
alone_ok[member] = fail
if fail is not None:
decisions.append(
{"plugin": member.name, "action": "disabled", "reason": fail}
)
survivors.remove(member)
# Phase 2: retry the reduced union; if it still fails, the remaining
# set conflicts mutually — incumbent wins, newest (last) disabled.
while survivors:
reason = _try(survivors)
if reason is None:
return survivors, decisions
loser = survivors.pop() # newest-enabled = last in the list
decisions.append(
{"plugin": loser.name, "action": "disabled", "reason": reason}
)
return survivors, decisions
def _default_venv_dir() -> Path:
from pm.packages import Venv
return Venv().venv_dir()
def _uv_binary() -> Optional[str]:
"""Store uv first (pinned), PATH uv second (dev machines, test envs).
A dev machine with uv on PATH but no provisioned store is the normal
pre-`pm install` state — 'uv is not installed' there is a lie."""
from pm.ensure import uv as pm_uv
uv_bin, _env = pm_uv(realize=False)
if uv_bin:
return uv_bin
import shutil
return shutil.which("uv")