Files
hermes-agent/pm/publication.py
ethernet b728fdd627 refactor(pm): own the lock/atomic-write primitives in pm.filesystem
pm imported runtime_state's private helpers (_lock, _atomic_bytes, _bytes,
_digest) at a dozen sites while runtime_state imports pm.environments at
module top. The primitives are pm's: move them into the stdlib-only
pm.filesystem as lock_fd, durable_write_bytes, read_bytes_or_none and
file_digest, and repoint every pm caller.

runtime_state keeps the private names only as import aliases: it still
calls them through its own globals, and pre-PM updaters load them by these
names mid-swap (tests/compat/old_updater_surface.json).

Boot-subset test fixtures now copy pm/filesystem.py, since runtime_state
imports it at process boot; worker-injection tests patch the name
pm.publication now reads.
2026-09-24 14:08:03 -04:00

176 lines
8.7 KiB
Python

"""Worker-local plugin selection and code publication. No application dependency imports.
Requests carry proposed data; discovery, snapshots and publication happen only
while the install lock is held. The stdlib boot journal owns crash recovery.
"""
from __future__ import annotations
import base64
import hashlib
import io
import json
import threading
from pathlib import Path
from pm.environments import dependency_home_root, install_state_dir, runtime_facts_path
from pm.filesystem import durable_write_bytes, file_digest, read_bytes_or_none
from pm.workspace import enabled_plugin_dirs, _is_member_candidate
_METADATA_LOCK_HOLDER = threading.local()
def _metadata_records(data: bytes | None) -> dict:
if data is None:
return {}
try:
records = json.loads(data)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("Plugin install metadata changed while preparing the update; retry.") from exc
if not isinstance(records, dict):
raise ValueError("Plugin install metadata changed while preparing the update; retry.")
return records
def candidate_members(extra_dirs=(), **selection):
selected = enabled_plugin_dirs(**selection)
for source in selected:
validate_manifest(source)
members = [source for source in selected if _is_member_candidate(source)]
for directory in extra_dirs:
directory = Path(directory)
if directory not in members and _is_member_candidate(directory):
members.append(directory)
return members
def selection_snapshot() -> dict[Path, bytes | None]:
from pm.plugins_state import dependency_homes
return {home / "config.yaml": read_bytes_or_none(home / "config.yaml") for home in dependency_homes()}
def validate_manifest(source: Path) -> dict:
from pm.plugin_declarations import read_python_declaration, manifest_version_error
manifest = read_python_declaration(source).manifest
reason = manifest_version_error(manifest, source.name)
if reason:
raise ValueError(reason)
return manifest
class PluginSelection:
def __init__(self, selection: dict):
from hermes_yaml import roundtrip_yaml
self.configs = selection_snapshot()
self.home = Path(selection["home"]).resolve()
if not self.home.is_relative_to(dependency_home_root().resolve()):
raise ValueError("config path is outside Hermes state")
self.path = self.home / "config.yaml"
self.previous = read_bytes_or_none(self.path)
expected = selection.get("expected_config")
actual = hashlib.sha256(self.previous).hexdigest() if self.previous is not None else "missing"
if expected is not None and expected != actual:
raise ValueError("Plugin configuration changed since this selection was read; retry.")
yaml = roundtrip_yaml()
config = yaml.load(self.previous.decode("utf-8-sig")) if self.previous else {}
if config is None:
config = {}
if not isinstance(config, dict):
raise ValueError(f"configuration must be a mapping: {self.path}")
plugins = config.setdefault("plugins", {})
if not isinstance(plugins, dict):
raise ValueError(f"plugins must be a mapping in {self.path}")
plugins["enabled"] = sorted(selection["enabled"])
plugins["disabled"] = sorted(selection["disabled"])
output = io.StringIO()
yaml.dump(config, output)
self.proposed = output.getvalue().encode("utf-8")
self.members = candidate_members(selection.get("extra_dirs", ()), proposed_home=self.home,
enabled=selection["enabled"], disabled=selection["disabled"])
def publish(self, project: Path) -> None:
if selection_snapshot() != self.configs or read_bytes_or_none(self.path) != self.previous:
raise ValueError("plugin configuration changed while preparing publication; retry")
row = {"config": str(self.path),
"previous": base64.b64encode(self.previous).decode() if self.previous is not None else None,
"facts_before": file_digest(runtime_facts_path(project)),
"config_after": hashlib.sha256(self.proposed).hexdigest()}
durable_write_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode())
durable_write_bytes(self.path, self.proposed)
class StagedPlugin:
def __init__(self, plugin: dict):
from pm.store import tree_digest
from pm.workspace import enabled_plugin_dirs, member_sources
self.configs = selection_snapshot()
self.target = Path(plugin["target"]).absolute()
self.staged = Path(plugin["staged"]).resolve()
if (not self.target.resolve().is_relative_to(dependency_home_root().resolve())
or self.target.parent.name != "plugins" or self.target.is_symlink()
or self.staged == self.target.resolve() or self.staged.is_relative_to(self.target.resolve())
or self.target.resolve().is_relative_to(self.staged)):
raise ValueError("plugin publication paths escape or overlap their home")
manifest = validate_manifest(self.staged)
if manifest.get("name", self.target.name) != self.target.name:
raise ValueError("The updated plugin changed its installed name; reinstall it explicitly.")
self.staged_digest = tree_digest(self.staged)
self.metadata = self.target.parent / ".install-metadata.json"
previous = read_bytes_or_none(self.metadata)
current = _metadata_records(previous)
self.old_record = plugin["old_metadata"].get(self.target.name)
if current.get(self.target.name) != self.old_record:
raise ValueError("Plugin install metadata changed while preparing the update; retry.")
self.new_record = plugin["new_metadata"].get(self.target.name)
if self.new_record is None:
raise ValueError("Plugin publication omitted its install metadata record.")
self.target_digest = tree_digest(self.target) if self.target.exists() else None
if self.target_digest != plugin["target_digest"]:
raise ValueError("Plugin files changed while preparing the update; retry.")
sources = member_sources(enabled_plugin_dirs(installing=self.target))
self.active = self.target.resolve() in sources
self.members = {}
if self.active:
sources[self.target.resolve()] = self.staged
for source in sources.values():
validate_manifest(source)
self.members = {identity: source for identity, source in sources.items() if _is_member_candidate(source)}
def publish(self, project: Path) -> None:
import os
import uuid
from hermes_cli.auth import _file_lock
from pm.store import tree_digest
if selection_snapshot() != self.configs:
raise ValueError("Plugin enablement changed while preparing the update; retry.")
if tree_digest(self.staged) != self.staged_digest:
raise ValueError("Staged plugin files changed while preparing the update; retry.")
lock = self.metadata.with_name(f"{self.metadata.name}.lock")
with _file_lock(lock, _METADATA_LOCK_HOLDER, 10.0,
"Timed out waiting for the plugin install metadata lock"):
previous = read_bytes_or_none(self.metadata)
metadata = _metadata_records(previous)
if metadata.get(self.target.name) != self.old_record:
raise ValueError("Plugin install metadata changed while preparing the update; retry.")
metadata[self.target.name] = self.new_record
proposed = (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode()
current = tree_digest(self.target) if self.target.exists() else None
if current != self.target_digest:
raise ValueError("Plugin files changed while preparing the update; retry.")
backup = self.target.parent / f".previous-{uuid.uuid4().hex}"
row = {
"kind": "plugin", "target": str(self.target), "backup": str(backup), "metadata": str(self.metadata),
"target_existed": self.target.exists(), "facts_before": file_digest(runtime_facts_path(project)),
"metadata_before": base64.b64encode(previous).decode() if previous is not None else None,
"metadata_after": base64.b64encode(proposed).decode(),
}
durable_write_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode())
if self.target.exists():
os.replace(self.target, backup)
os.replace(self.staged, self.target)
durable_write_bytes(self.metadata, proposed)