Files
hermes-agent/pm/workspace.py
ethernet f3b1399211 fix: round-5 CI backlog after the 779-commit main merge
Merge fallout (my resolution errors, all caught by CI):
- hermes_cli/backup.py + gateway.py: `theirs` on those hunks re-imported clusters HEAD had
  already moved to backup_restore.py / kept in the facade. backup.py loses the 349-line
  duplicate (main's #110179 fix is ported into backup_restore._import_db_member); the
  systemd service-unit cluster returns to gateway.py (PM's _prepare_service_launcher /
  _pm_managed_node_dirs / _systemd_command have no home in main's extraction) with main's
  utf-8-sig read. gateway_service_unit.py is dropped.
- gateway/run.py: main's plugin-update chore is not profile-scoped (the housekeeping
  ordering test pins the scope/drain sequence).
- pyproject + 30 test files: `import yaml` -> `import hermes_yaml as yaml` (pm-clean has no
  pyyaml); gateway/config._bundled_platform_manifest_name reads through hermes_yaml.
- tests re-seamed onto pm-clean's shape: residency admission (installed_engine),
  supervisor child env (binary is a constructor argument), update import guard
  (update_cmd_deps is gone; our probe already scrubs PYTHONPATH — both #115032 invariants
  pass), shallow-count git responses (stash path asks `status --porcelain -z`); dropped
  tests for retired code (_run_node_bootstrap/_ensure_tui_node, Windows resume demotion).
- tests/tools/test_local_env_blocklist.py: restore the two helpers the suite-reduction
  commit dropped and the blocklist import.

Real fixes:
- pm: classify_uv_failure/ResolutionConflict move beside the uv runner (pm.environment,
  stdlib-only). pm.workspace imports tomllib at module level and cannot load on the 3.10
  bootstrap python that streams uv output in the Docker arm64 image.
- tools/browser_tool.warm_agent_browser_npx_cache: back as a permanent definition — it is on
  the frozen old-updater surface, and the revert-scheduled compat pointer does not count.
- hermes_cli/memory_setup: the dashboard's pip row uses pm.environments.
  running_from_selected_environment for installed vs restart_required.
- scripts/windows-build-deps.ps1: export DISTUTILS_USE_SDK/MSSdk so setuptools trusts the
  primed MSVC environment instead of asking vswhere (`env -i` test runner on win32-arm64
  compiling ruamel-yaml-clib); run_tests.sh forwards them.
- tests/pm/test_windows_build_deps.py: start the protocol test from a parent env without the
  toolchain variables the runner job already exports.
- tests/conftest.py scrubs HERMES_BUNDLED_PLUGINS (Nix-wrapped hermes on the dev host);
  tests/home_io_guard.py treats sys.path site-packages under the real home as the
  interpreter's installation (PM-activated developer shell).
- tests-js: four `curly` lint errors from main's new scripts.
2026-09-21 02:47:48 -04:00

298 lines
13 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"))
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
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)]
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"
_copy_core_inputs(source, root)
target.write_text(text, encoding="utf-8")
def _is_member_candidate(plugin_dir: Path) -> bool:
return read_python_declaration(plugin_dir).is_member
def enabled_plugin_dirs(*, proposed_home=None, enabled=None, disabled=None, installing: Path | None = 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(
proposed_home=proposed_home, enabled=enabled, disabled=disabled, installing=installing,
)
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
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):
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."""
selected = enabled_plugin_dirs(proposed_home=proposed_home, enabled=enabled, disabled=disabled)
members = []
for path in selected:
declaration = read_python_declaration(path)
reason = manifest_version_error(declaration.manifest, path.name)
if reason:
raise InstallError("venv", reason)
if declaration.is_member:
members.append(path)
return members
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 = hashlib.sha256(str(identity.resolve()).encode()).hexdigest()[:16]
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"))
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 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)