feat(pm): generated uv-workspace root unions plugin deps into the venv

pm/workspace.py owns the generated workspace root (settled design:
.hermes/plans/2026-09-02_164500-plugin-deps-workspace-union.md):

- build_root() writes <store_root>/.pm-workspace/pyproject.toml =
  core's pyproject verbatim + [tool.uv.workspace] members pointing at
  each enabled plugin dir via relative ../-escaping paths. The
  committed pyproject is never touched (sealed installs are read-only;
  member lists are machine-specific).
- enabled_member_dirs() scans all profiles' plugin dirs for python-dep
  declarations: pyproject.toml (modern) or legacy pip_dependencies/
  python_dependencies in plugin.yaml (bridge candidates). Per-install
  union (settled): profiles share the venv, so their plugins share the
  resolution graph. OSError-safe walks (dangling junctions).
- lock_and_sync(): build root -> uv lock -> uv sync --frozen --extras
  into the venv (UV_PROJECT_ENVIRONMENT pinned). One lock, one graph:
  core pins preserved (proven live: union of the real repo pyproject +
  a plugin kept rich==14.3.3 / httpx==0.28.1 while adding the plugin's
  own dep), conflict = loud resolver refusal naming both sides
  (proven live: a plugin pinning rich==13.9.4 against core's 14.3.3).
- Venv.expected_stamp folds in members_stamp() so a changed plugin set
  re-syncs; Venv.apply routes through the workspace root when members
  exist, plain uv sync otherwise (no behavior change for
  zero-plugin installs).

tests/pm/test_workspace.py: 8 tests (root location, verbatim core
carry-over, relative escaping members, idempotent build, zero-member
build, stamp hashing, member discovery incl. legacy + broken-root
resilience). tests/pm suite: 152 passed, 0 failed.
This commit is contained in:
ethernet
2026-09-02 19:43:35 -04:00
parent 52510d26fd
commit 878d33c3ad
3 changed files with 356 additions and 0 deletions

View File

@@ -303,10 +303,23 @@ class Venv(StatePackage):
h.update(_uv_lock_digest(self.project_root() / "uv.lock"))
h.update(",".join(sorted(extras)).encode())
h.update(f"{sys.version_info.major}.{sys.version_info.minor}".encode())
# Plugin members union into the venv — a changed member set must
# re-sync even when extras and core lock are unchanged.
from pm.workspace import enabled_member_dirs, members_stamp
h.update(members_stamp(enabled_member_dirs()).encode())
return h.hexdigest()
def apply(self, extras: list[str]) -> None:
from pm.ensure import uv as pm_uv
from pm.workspace import enabled_member_dirs, lock_and_sync
member_dirs = enabled_member_dirs()
if member_dirs:
# Plugin deps union into the venv through the generated
# workspace root (one lock, conflict = loud refusal).
lock_and_sync(member_dirs, extras, venv_dir=self.venv_dir())
return
uv_bin, env = pm_uv(venv=self.venv_dir())
if uv_bin is None: