Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
"""Validate a proposed plugin set, then publish config and runtime selection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Iterable, Optional
|
|
|
|
|
|
class AdmissionRefused(RuntimeError):
|
|
"""The candidate set was refused; config and environment untouched."""
|
|
|
|
|
|
def candidate_member_dirs(
|
|
candidate_enabled: Iterable[str],
|
|
candidate_disabled: Iterable[str] = (),
|
|
*,
|
|
active_plugins_dir: Optional[Path] = None,
|
|
extra_dirs: Iterable[Path] = (),
|
|
) -> list[Path]:
|
|
"""Member dirs implied by the PROPOSED enabled sets: per plugins dir,
|
|
its enabled names — the active dir's replaced by the candidate set
|
|
(removals excluded), other homes unchanged — filtered to dirs that
|
|
actually declare python deps. ``extra_dirs`` (the install target)
|
|
join when they declare deps."""
|
|
from pm.workspace import enabled_member_dirs, _is_member_candidate
|
|
|
|
active = Path(active_plugins_dir) if active_plugins_dir else None
|
|
members = enabled_member_dirs(
|
|
proposed_home=active.parent if active else None,
|
|
enabled=candidate_enabled, disabled=candidate_disabled,
|
|
)
|
|
for directory in extra_dirs:
|
|
directory = Path(directory)
|
|
if directory not in members and _is_member_candidate(directory):
|
|
members.append(directory)
|
|
return members
|
|
|
|
|
|
def _config_path() -> Path:
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
return get_hermes_home() / "config.yaml"
|
|
|
|
|
|
def _config_commit(candidate_enabled: set, candidate_disabled: set):
|
|
"""Journal both config versions before publishing either config or facts."""
|
|
import shutil
|
|
import tempfile
|
|
|
|
from pm.paths import repo_root
|
|
from hermes_cli.runtime_state import begin_publication, _atomic_bytes
|
|
from hermes_cli.config import read_raw_config
|
|
from utils import atomic_roundtrip_yaml_save
|
|
|
|
path = _config_path()
|
|
config = read_raw_config()
|
|
plugins_cfg = config.setdefault("plugins", {})
|
|
if not isinstance(plugins_cfg, dict):
|
|
raise ValueError(f"plugins must be a mapping in {path}")
|
|
plugins_cfg["enabled"] = sorted(candidate_enabled)
|
|
plugins_cfg["disabled"] = sorted(candidate_disabled)
|
|
with tempfile.TemporaryDirectory(prefix="hermes-admission-") as temporary:
|
|
staged = Path(temporary) / "config.yaml"
|
|
if path.is_file():
|
|
shutil.copyfile(path, staged)
|
|
atomic_roundtrip_yaml_save(staged, config)
|
|
proposed = staged.read_bytes()
|
|
publication = begin_publication(repo_root(), path, proposed)
|
|
try:
|
|
_atomic_bytes(path, proposed)
|
|
except BaseException:
|
|
publication()
|
|
raise
|
|
return publication
|
|
|
|
|
|
def admit_plugin_set_change(
|
|
candidate_enabled: set,
|
|
candidate_disabled: set,
|
|
*,
|
|
active_plugins_dir: Optional[Path] = None,
|
|
extra_dirs: Iterable[Path] = (),
|
|
) -> None:
|
|
"""Validate the proposed sets against the active environment and
|
|
commit — env selection and config move together, inside pm's single
|
|
locked transaction.
|
|
|
|
Raises :class:`AdmissionRefused` BEFORE anything is published when
|
|
the candidate union fails to resolve (conflict, frozen feature set,
|
|
…) or when the config write itself fails. The active environment and
|
|
the previous config bytes are kept EXACTLY — no rollback re-resolve.
|
|
"""
|
|
from pm.client import sync_venv
|
|
|
|
extra_dirs = tuple(extra_dirs)
|
|
try:
|
|
sync_venv(
|
|
explicit=True,
|
|
plugin_dirs=lambda: candidate_member_dirs(
|
|
candidate_enabled, candidate_disabled, active_plugins_dir=active_plugins_dir, extra_dirs=extra_dirs
|
|
),
|
|
before_publish=lambda: _config_commit(candidate_enabled, candidate_disabled),
|
|
)
|
|
except Exception as exc:
|
|
raise AdmissionRefused(str(exc)) from exc
|