Files
hermes-agent/pm/workspace.py
teknium1 9fa34cfb28 fix(pm): keep bundled plugins' dashboard dist/ in the build snapshot
`dist` sat in the same every-depth exclusion set, so the generation
workspace lost the tracked `plugins/kanban/dashboard/dist/` and
`plugins/hermes-achievements/dashboard/dist/` bundles. The managed
environment's editable install runs from that snapshot, so
`get_bundled_plugins_dir()` resolves into it and
`dashboard_ui.py::serve_plugin_asset` 404s both tabs' entry bundles.

Keep `dist` excluded at the root (build output) and copy it below a
package root.

Refs #124075

Co-authored-by: Rafsanjani Castro Satria Chandra <330302617+oswaldvalois@users.noreply.github.com>
2026-09-27 02:54:35 -07:00

383 lines
17 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 shutil
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
from pm.plugin_declarations import read_python_declaration, manifest_version_error
_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")]
# The uv failure classifier lives beside the uv runner (stdlib-only imports): the bootstrap
# runner streams uv output from a pre-3.11 system python where this module's tomllib import
# cannot load. Workspace callers keep reaching it from here.
from pm.environment import ResolutionConflict, classify_uv_failure # noqa: E402,F401
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")
declaration = read_python_declaration(entry)
for source in declaration.files:
h.update(source.name.encode("utf-8"))
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 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"))
# uv.lock is not excluded: the root lock is never copied (only ``files`` are; lock_and_sync
# seeds or resolves it), and pm/uv.lock is the PM runtime's input (pm/runtime.py::_inputs).
excluded = {".git", ".venv", "venv", "node_modules", "__pycache__", "build", "dist", "release"}
# A root dist/ is build output, but below a package root it is shipped: the managed
# environment runs from this snapshot and serves bundled plugins' dashboard/dist/.
nested_excluded = excluded - {"dist"}
def ignore(directory, names):
return [name for name in names if name in nested_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
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] | Mapping[Path, Path], root: Path, *, source: Path) -> None:
"""Snapshot core and plugin build inputs into a fresh generation."""
source = 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)
core_pyproject = source / "pyproject.toml"
core_text = core_pyproject.read_text(encoding="utf-8-sig")
members = [_workspace_member(source, root, identity=identity).relative_to(root).as_posix()
for identity, source in member_sources(plugin_dirs).items()
if _is_member_candidate(source)]
if members:
import tomllib
import tomli_w
document = tomllib.loads(core_text)
_core_release_quarantine(document, source / "uv.lock")
document.setdefault("tool", {}).setdefault("uv", {})["workspace"] = {"members": sorted(members)}
text = tomli_w.dumps(document)
else:
# Byte-identical to core: a member-less generation syncs frozen against core's own lock.
text = core_text.rstrip("\n") + "\n"
target = root / "pyproject.toml"
_copy_core_inputs(source, root)
target.write_text(text, encoding="utf-8")
def _core_release_quarantine(document: dict, core_lock: Path) -> None:
"""Scope core's ``exclude-newer`` to the packages in core's own lock.
The quarantine covers Hermes's own dependencies only; a plugin's dependencies
follow the plugin's own policy, so a catalog pin floored on a fresh release still
installs. A global cutoff would filter plugin-only packages too, so it moves onto
every registry package core locks. A plugin still cannot drag one of those past
the window, and core's own ``= false`` exemptions stay as written.
"""
import re
import tomllib
settings = document.get("tool", {}).get("uv", {})
cutoff = settings.pop("exclude-newer", None)
if cutoff is None:
return
def normalized(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower()
per_package = {normalized(name): value
for name, value in settings.get("exclude-newer-package", {}).items()}
for package in tomllib.loads(core_lock.read_text(encoding="utf-8-sig")).get("package", []):
if "registry" in package.get("source", {}):
per_package.setdefault(normalized(package["name"]), cutoff)
settings["exclude-newer-package"] = per_package
def _is_member_candidate(plugin_dir: Path) -> bool:
return read_python_declaration(plugin_dir).is_member
def enabled_plugin_entries(*, proposed_home=None, enabled=None, disabled=None,
installing: Path | None = None,
skip_invalid_secondary: bool = False) -> list[tuple[Path, str, Path]]:
"""``(home plugins dir, selection key, plugin dir)`` for every selected plugin, in config order.
The key is what the home's config names, so a caller can edit that home's selection.
"""
from pm.plugins_state import _is_directory, enabled_plugins_ordered
selection = enabled_plugins_ordered(
proposed_home=proposed_home, enabled=enabled, disabled=disabled, installing=installing,
skip_invalid_secondary=skip_invalid_secondary,
)
entries = []
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
proposed = installing is not None and plugin_dir.resolve() == installing.resolve()
if not proposed and not _is_directory(plugin_dir):
plugin_dir = paths.repo_root() / "plugins" / relative
if proposed or _is_directory(plugin_dir):
entries.append((plugins_dir, name, plugin_dir))
return entries
def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None,
installing: Path | None = None, skip_invalid_secondary: bool = False) -> list[Path]:
"""Resolve the effective plugin selection without filtering dependency declarations."""
entries = enabled_plugin_entries(proposed_home=proposed_home, enabled=enabled, disabled=disabled,
installing=installing, skip_invalid_secondary=skip_invalid_secondary)
return list(dict.fromkeys(plugin_dir for _plugins_dir, _name, plugin_dir in entries))
def enabled_member_dirs(*, proposed_home=None, enabled=None, disabled=None) -> list[Path]:
"""Keep every selected member or refuse an incompatible selection.
A member whose requires_hermes rejects the running version sits out instead: the
verdict is only as good as our version identity (an untagged source checkout reads
as an older release), the loader skips that plugin anyway, and the member rejoins
as soon as the verdict flips. Enabling one is still refused at admission.
"""
selected = enabled_plugin_dirs(proposed_home=proposed_home, enabled=enabled, disabled=disabled,
skip_invalid_secondary=proposed_home is None)
members = []
for path in selected:
# Per plugin: with none selected, PM must not import the application's manifest module.
from hermes_cli.plugins_manifest import requires_hermes_error
declaration = read_python_declaration(path)
if requires_hermes_error(declaration.manifest):
continue
reason = manifest_version_error(declaration.manifest, path.name)
if reason:
raise InstallError("venv", reason)
if declaration.is_member:
members.append(path)
return members
def _member_key(identity: Path) -> str:
"""``<plugin dir name>-<sha256(path)[:16]>``: the hash keeps two same-named plugins from
different homes apart; the name is what a user sees in uv's conflict text
(``hermes-plugin-<key> depends on …``) — a bare hash told them nothing to disable."""
import re
digest = hashlib.sha256(str(identity.resolve()).encode()).hexdigest()[:16]
name = re.sub(r"[^a-z0-9._-]+", "-", identity.name.lower()).strip("-.") or "plugin"
return f"{name}-{digest}"
def _workspace_member(plugin_dir: Path, root: Path, *, identity: Path) -> Path:
"""Keep workspace members with their generation, not a temporary install clone."""
import json
import tomllib
key = _member_key(identity)
declaration = read_python_declaration(plugin_dir)
pyproject = declaration.pyproject
if pyproject is not None:
member = root / "plugin-sources" / key
shutil.copytree(plugin_dir, member, symlinks=True,
ignore=_member_ignored)
document = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
# uv identifies a workspace member by [project].name, so the same virtual
# plugin enabled in two profiles would declare one name twice and fail
# `uv lock`. A member with no build backend is metadata-only: it can carry
# the unique key in its name, as manifest-only members already do. A
# buildable member keeps its declared name — uv verifies it against the
# package metadata its backend produces.
# tool.uv.package = true opts into uv package mode: real build metadata, so buildable.
virtual = "build-system" not in document and document.get("tool", {}).get("uv", {}).get("package") is not True
changed = declaration.install_requirements != declaration.requirements
if changed:
document["project"]["dependencies"] = list(declaration.install_requirements)
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 / relative).resolve().as_posix()
changed = True
if virtual:
document.setdefault("project", {})["name"] = f"hermes-plugin-{key}"
if virtual or changed:
import tomli_w
(member / "pyproject.toml").write_text(tomli_w.dumps(document), encoding="utf-8")
return member
specs = declaration.install_requirements
member = root / "plugin-deps" / key
member.mkdir(parents=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 install_node_sidecar(
plugin_dir: Path,
*,
explicit: bool = False,
) -> Optional[str]:
"""Install plugin-local dependencies using PM's paired npm/Node context.
Explicit user consent permits acquisition even when on-demand installs
are disabled. Returns None on success, otherwise a diagnostic.
"""
package_json = plugin_dir / "package.json"
if not package_json.is_file():
return None # nothing to install
import pm
from pm.install import lazy_installs_allowed
# Tool availability does not authorize mutation of the sidecar itself.
if not explicit and not lazy_installs_allowed():
return "lazy installs are disabled — run `hermes plugins install` and approve Node dependencies"
# a lockfile means reproducible `npm ci`; plain `npm install` otherwise
install_cmd = ["ci"] if (plugin_dir / "package-lock.json").is_file() else ["install"]
try:
runner = pm.ensure("npm", explicit=explicit)
# Resolve inside the composed context, including npm.cmd on Windows;
# CreateProcess does not search a child's replacement PATH itself.
npm = shutil.which("npm", path=runner.env.get("PATH", ""))
if npm is None:
return "npm is missing from the prepared PM environment"
proc = runner.run(
[npm, *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 lock_and_sync(
plugin_dirs: list[Path] | Mapping[Path, Path],
extras: list[str],
*,
root: Path,
source: Path,
seed_lock: Path | None,
environment: PythonEnvironment,
frozen: bool = False,
replay: Path | None = None,
) -> None:
"""Prepare a fresh generation using explicit inputs and a prepared engine.
The caller selects the seed; uv retains its compatible versions. Repair
copies the recorded build inputs and never reads current manifests.
Resolver conflicts remain distinct from download/build failures.
"""
if root.exists() or root.is_symlink():
raise InstallError("venv", f"workspace must be fresh: {root}")
if replay is None:
_generate_pyproject(plugin_dirs, root, source=source)
if seed_lock is not None:
(root / "uv.lock").write_bytes(seed_lock.read_bytes())
else:
if not (replay / "pyproject.toml").is_file() or not (replay / "uv.lock").is_file():
raise InstallError("venv", f"recorded workspace is missing: {replay}")
# Sibling generations keep external relative paths at the same depth;
# snapshotted members and their exact lock travel with the workspace.
# Use the snapshot's exclusions: build/ may hold an in-tree backend.
shutil.copytree(replay, root, symlinks=True, ignore=_member_ignored)
frozen = True
environment.sync(root, extras=extras, frozen=frozen)