Files
hermes-agent/pm/workspace.py

478 lines
20 KiB
Python

"""Resolve core plus plugin requirements in a writable build snapshot.
Shipped source and locks are inputs, never mutation targets. Candidate
failure propagates without changing plugin configuration or the live venv.
"""
from __future__ import annotations
import hashlib
import os
import subprocess
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from pm.environment import PythonEnvironment
from pm import paths
from pm.package import InstallError
WORKSPACE_DIRNAME = ".pm-workspace"
_MEMBER_EXCLUDE = frozenset({".git", ".venv", "venv", "node_modules", "__pycache__"})
def _member_ignored(directory, names):
return [name for name in names if name in _MEMBER_EXCLUDE or name.endswith(".egg-info")]
class ResolutionConflict(InstallError):
"""uv's resolver proved the union has no valid solution."""
# Markers uv prints ONLY when the resolver itself proves no solution
# exists (its conflict report: "Because ...", "no solution found").
# Deliberately narrow: a fetch timeout or index outage must not be
# misread as a conflict — and regardless of classification, nothing
# here ever disables a plugin; the caller decides.
_RESOLVER_MARKERS = (
"no solution found",
"conflicting requirements",
"conflicting urls",
"because only the following versions",
"and your pyproject depends on",
)
def classify_uv_failure(stage: str, returncode: int, output: str) -> InstallError:
"""Turn a failed `uv <stage>` into the right classified error.
Resolver-conflict output → ResolutionConflict; anything else (fetch,
build, tooling) → plain InstallError with the tail of the output.
"""
cause = f"uv {stage} exited {returncode}: {output.strip()[-600:]}"
lowered = output.lower()
if any(marker in lowered for marker in _RESOLVER_MARKERS):
return ResolutionConflict("venv", cause)
return InstallError("venv", cause)
def workspace_root() -> Path:
"""Default preparation root; callers can supply a fresh transaction root."""
from hermes_cli.runtime_paths import install_state_dir
return install_state_dir(paths.repo_root()) / WORKSPACE_DIRNAME
def _member_rel(root: Path, plugin_dir: Path) -> str:
"""Use portable separators for a member inside the generated workspace."""
return os.path.relpath(plugin_dir.resolve(), root.resolve()).replace("\\", "/")
def member_sources(plugin_dirs) -> dict[Path, Path]:
"""Map installed identities to build inputs, including staged plugin updates."""
rows = plugin_dirs.items() if isinstance(plugin_dirs, Mapping) else ((path, path) for path in plugin_dirs)
return {Path(identity).resolve(): Path(source).resolve() for identity, source in rows}
def members_stamp(plugin_dirs) -> str:
"""Hash the member inputs copied into a generation, independent of staging paths."""
h = hashlib.sha256()
for identity, entry in sorted(member_sources(plugin_dirs).items()):
h.update(str(identity).encode("utf-8"))
h.update(b"\0")
for name in ("pyproject.toml", "plugin.yaml"):
source = entry / name
if source.is_file():
h.update(source.read_bytes())
h.update(b"\0")
if (entry / "pyproject.toml").is_file():
for directory, dirs, files in os.walk(entry):
dirs[:] = sorted(set(dirs) - set(_member_ignored(directory, dirs)))
for name in sorted(set(files) - set(_member_ignored(directory, files))):
path = Path(directory) / name
h.update(path.relative_to(entry).as_posix().encode())
h.update(b"\0")
h.update(os.readlink(path).encode() if path.is_symlink() else path.read_bytes())
h.update(b"\0")
return h.hexdigest()
def _copy_core_inputs(source: Path, destination: Path) -> None:
"""Build from a writable snapshot, never from signed/read-only source."""
import shutil
import fnmatch
import tomllib
metadata = tomllib.loads((source / "pyproject.toml").read_text(encoding="utf-8-sig"))
project = metadata.get("project", {})
setuptools = metadata.get("tool", {}).get("setuptools", {})
patterns = setuptools.get("packages", {}).get("find", {}).get("include", ["*"])
package_roots = {pattern.split(".", 1)[0] for pattern in patterns}
files = {"pyproject.toml", "setup.py", "setup.cfg"}
readme = project.get("readme")
if isinstance(readme, str):
files.add(readme)
elif isinstance(readme, dict) and "file" in readme:
files.add(readme["file"])
for pattern in project.get("license-files", []):
files.update(str(p.relative_to(source)) for p in source.glob(pattern))
files.update(p.name for p in source.glob("*.py"))
excluded = {".git", ".venv", "venv", "node_modules", "__pycache__", "build", "dist", "release", "uv.lock"}
def ignore(directory, names):
return [name for name in names if name in excluded or name.startswith(".")
or name.endswith(".egg-info") or (Path(directory) / name).is_symlink()]
for entry in source.iterdir():
if (entry.is_dir() and not entry.is_symlink() and entry.name not in excluded
and not entry.name.startswith(".") and entry.resolve() != destination.resolve()
and any(fnmatch.fnmatchcase(entry.name, pattern) for pattern in package_roots)):
target = destination / entry.name
if target.exists():
shutil.rmtree(target)
shutil.copytree(entry, target, ignore=ignore)
for name in files:
entry = source / name
if not entry.is_file() or entry.is_symlink():
continue
if not entry.resolve().is_relative_to(source.resolve()):
raise InstallError("venv", f"build input escapes the core project: {name}")
target = destination / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(entry, target)
def _generate_pyproject(plugin_dirs: list[Path], root: Optional[Path] = None, *,
source: Optional[Path] = None) -> tuple[Path, bool]:
"""(Re)generate the workspace root's pyproject.toml from core's
pyproject + the enabled plugin members. Idempotent — same inputs,
same bytes. Returns (root, changed): changed is True when the member
surface moved (member set or a member's pyproject content), which is
the signal to re-seed the resolution from the committed lock."""
if root is None:
root = workspace_root()
source = (paths.repo_root() if source is None else source).resolve()
if root.resolve() == source or source.is_relative_to(root.resolve()):
raise InstallError("venv", "workspace must not replace the core source")
root.mkdir(parents=True, exist_ok=True)
core_pyproject = source / "pyproject.toml"
core_text = core_pyproject.read_text(encoding="utf-8-sig")
members = [_member_rel(root, _workspace_member(source, root, identity=identity))
for identity, source in member_sources(plugin_dirs).items()]
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)) + "]")
text = "\n".join(lines) + "\n"
target = root / "pyproject.toml"
try:
changed = target.read_text(encoding="utf-8") != text
except OSError:
changed = True
_copy_core_inputs(source, root)
target.write_text(text, encoding="utf-8")
return root, changed
def build_root(plugin_dirs: list[Path], root: Optional[Path] = None) -> Path:
"""(Re)generate the workspace root's pyproject.toml. ``root`` pins a
parent-supplied STAGING workspace (tests, staged syncs); default is
the per-install generated root beside the byte store."""
generated, _changed = _generate_pyproject(plugin_dirs, root)
return generated
def _seed_lock(root: Path, seed_lock: Optional[Path] = None, *, source: Optional[Path] = None) -> None:
"""Seed the generated root's uv.lock with the CURRENT resolution.
Seed precedence: the parent-supplied ``seed_lock`` path first, then
the root's own existing uv.lock (the current EXTENDED resolution from
the previous sync), then the committed core lock — so a plugin-driven
extension keeps every compatible selection it already made, and a
fresh root extends the committed resolution. uv preserves compatible
selections from the seed (a plugin's range spec does not move core
pins); explicit exact requirements stay binding as declared
constraints. The lock is COPIED — shipped/extended source bytes are
never rewritten; only the staging root receives the copy.
Called only when the member surface changed; an unchanged root keeps
its lock untouched, so repeated syncs are stable. Seed failures
SURFACE (they would silently degrade the resolution otherwise)."""
if seed_lock is None:
existing = root / "uv.lock"
if existing.is_file():
seed_lock = existing
else:
seed_lock = (paths.repo_root() if source is None else source) / "uv.lock"
if not seed_lock.is_file():
return # nothing committed to seed from; uv resolves from scratch
(root / "uv.lock").write_bytes(seed_lock.read_bytes())
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)."""
import stat
for name in ("pyproject.toml", "plugin.yaml"):
path = plugin_dir / name
try:
mode = path.stat().st_mode
except FileNotFoundError:
continue
except OSError as exc:
raise ValueError(f"could not inspect plugin metadata: {path}") from exc
if not stat.S_ISREG(mode):
continue
if name == "pyproject.toml":
return True
try:
text = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as exc:
raise ValueError(f"could not read plugin metadata: {path}") from exc
return "pip_dependencies" in text or "python_dependencies" in text
return False
def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]:
"""Resolve the effective plugin selection without filtering dependency declarations."""
from pm.plugins_state import _is_directory, enabled_plugins_ordered
selection = enabled_plugins_ordered() if proposed_home is None else enabled_plugins_ordered(
proposed_home=proposed_home, enabled=enabled, disabled=disabled,
)
members = []
for plugins_dir, names in selection.items():
for name in names:
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise InstallError("venv", f"invalid plugin key: {name}")
plugin_dir = plugins_dir / relative
if not _is_directory(plugin_dir):
plugin_dir = paths.repo_root() / "plugins" / relative
if _is_directory(plugin_dir):
members.append(plugin_dir)
return list(dict.fromkeys(members))
def enabled_member_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]:
"""Keep every selected member or refuse an incompatible selection."""
from hermes_cli.plugins_cmd import _check_manifest_version, _read_manifest_for_install
selected = enabled_plugin_dirs(proposed_home=proposed_home, enabled=enabled, disabled=disabled)
for path in selected:
_check_manifest_version(_read_manifest_for_install(path), path.name)
return [path for path in selected if _is_member_candidate(path)]
def _legacy_requirements(plugin_dir: Path) -> list[str]:
from utils import fast_safe_load
manifest = plugin_dir / "plugin.yaml"
if not manifest.is_file():
return []
data = fast_safe_load(manifest.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise InstallError("venv", f"invalid plugin manifest: {manifest}")
specs = []
for key in ("pip_dependencies", "python_dependencies"):
values = data.get(key, [])
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
raise InstallError("venv", f"invalid {key}: {manifest}")
specs.extend(values)
return list(dict.fromkeys(specs))
def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path | None = None) -> Path:
"""Keep workspace members with their generation, not a temporary install clone."""
import json
import shutil
import tomllib
key = hashlib.sha256(str((identity or plugin_dir).resolve()).encode()).hexdigest()[:16]
pyproject = plugin_dir / "pyproject.toml"
if pyproject.is_file() and "GENERATED by pm" not in pyproject.read_text(encoding="utf-8-sig"):
member = root / "plugin-sources" / key
if member.exists():
shutil.rmtree(member)
shutil.copytree(plugin_dir, member, symlinks=True,
ignore=_member_ignored)
document = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
changed = False
for sources in document.get("tool", {}).get("uv", {}).get("sources", {}).values():
for spec in sources if isinstance(sources, list) else [sources]:
if not isinstance(spec, dict) or "path" not in spec:
continue
relative = Path(spec["path"])
if relative.is_absolute():
continue
resolved = (plugin_dir / relative).resolve()
if resolved.is_relative_to(plugin_dir.resolve()):
continue # The referenced tree was copied with this member.
spec["path"] = ((identity or plugin_dir) / relative).resolve().as_posix()
changed = True
if changed:
import tomli_w
(member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8")
return member
specs = _legacy_requirements(plugin_dir)
member = root / "plugin-deps" / key
member.mkdir(parents=True, exist_ok=True)
(member / "pyproject.toml").write_text(
f'[project]\nname = "hermes-plugin-{key}"\nversion = "0.0.0"\n'
'requires-python = ">=3.11"\n'
f'dependencies = {json.dumps(specs)}\n[tool.uv]\npackage = false\n',
encoding="utf-8",
)
return member
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 install_node_sidecar(
plugin_dir: Path,
*,
npm_bin: Optional[str] = None,
runner=subprocess.run,
) -> Optional[str]:
"""`npm ci` the plugin's package.json into ITS OWN node_modules —
the declared sidecar install (plugin-deps plan §B item 2; wired here).
Plugin-local (never a global npm prefix), pm's pinned npm when the
store has one (ambient PATH npm otherwise), gated by the lazy-install
policy, receipt-noted. Returns None on success, else why not.
"""
package_json = plugin_dir / "package.json"
if not package_json.is_file():
return None # nothing to install
from pm.ensure import lazy_installs_allowed
if not lazy_installs_allowed():
return "lazy installs are disabled — run `hermes pm install` after enabling"
# a lockfile means reproducible `npm ci`; plain `npm install` otherwise
install_cmd = ["ci"] if (plugin_dir / "package-lock.json").is_file() else ["install"]
if npm_bin is None:
npm_bin = _node_npm_binary("npm")
if npm_bin is None:
return "npm not found (pm store or PATH)"
try:
proc = runner(
[npm_bin, *install_cmd, "--no-audit", "--no-fund"],
cwd=str(plugin_dir),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=900,
)
except Exception as exc:
return f"npm {install_cmd[0]} failed to run: {exc}"
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-300:]
return f"npm {install_cmd[0]} exited {proc.returncode}: {tail}"
return None
def _node_npm_binary(name: str) -> Optional[str]:
"""pm's pinned npm from the store (store-first), PATH second."""
from pm.ensure import env_for
try:
env = env_for("npm")
except Exception:
env = None
if env:
path_value = env.get("PATH", "")
import shutil as _shutil
for d in path_value.split(os.pathsep):
if d:
candidate = Path(d) / ("npm.cmd" if os.name == "nt" else name)
if candidate.is_file():
return str(candidate)
import shutil as _shutil
return _shutil.which(name)
def lock_and_sync(
plugin_dirs: list[Path],
extras: Optional[list[str]] = None,
*,
venv_dir: Path,
root: Optional[Path] = None,
env: Optional[dict] = None,
seed_lock: Optional[Path] = None,
frozen: bool = False,
replay: Optional[Path] = None,
source: Optional[Path] = None,
environment: PythonEnvironment | None = None,
) -> None:
"""Build the root, then `uv lock` + `uv sync --frozen --extra ...`.
Everything resolves into a parent-supplied STAGING surface: ``root``
pins the generated workspace dir, ``venv_dir`` pins
UV_PROJECT_ENVIRONMENT and ``seed_lock`` (optional) pins which
existing lock seeds the extension (default: the root's current
extended lock, else the committed core lock). ``env`` replaces the
ambient base environment when supplied; either way the subprocess
gets a COPY — the live process environment is never mutated. ``replay``
copies a recorded sibling workspace and uses its lock without resolution
or plugin discovery; it is reserved for restoring an existing selection.
``source`` and ``environment`` bypass live source/tool discovery when supplied;
the environment owns the child process policy, cache and interpreter.
Raises a CLASSIFIED InstallError on failure: ResolutionConflict only
for a confirmed resolver conflict; network, build and tool failures
stay generic InstallError — they are not evidence of a dependency
conflict and must not disable plugins.
"""
if environment is not None and environment.destination != venv_dir:
raise ValueError("workspace and environment destinations differ")
if replay is None:
generated, changed = _generate_pyproject(plugin_dirs, root, source=source)
if changed:
_seed_lock(generated, seed_lock, source=source)
else:
import shutil
if root is None or not (replay / "pyproject.toml").is_file() or not (replay / "uv.lock").is_file():
raise InstallError("venv", f"recorded workspace is missing: {replay}")
# Generation workspaces are siblings at the same depth. External
# member paths still resolve. Generated members move with the copy.
shutil.copytree(replay, root, ignore=shutil.ignore_patterns("__pycache__", ".venv", "build", "*.egg-info"))
generated = root
frozen = True
if environment is None:
from pm.environment import managed_environment
environment = managed_environment(venv_dir, env=env)
environment.sync(generated, extras=extras or (), frozen=frozen)