Files
hermes-agent/pm/lock.py
ethernet 8b7eae99ef fix(pm): own interpreter selection and dependency recovery
Pin uv and uvx to the PM interpreter instead of ambient Python discovery.
A matching dependency stamp cannot prove that installed files still exist.
Repair now rebuilds the recorded workspace and lock in a fresh generation,
checks startup imports, and publishes the selection only after success.

Run startup recovery before dependency activation. Keep manual PM repair
reachable when the selected environment is damaged. Preserve plugin
selection, retry ownership, and the previous generation on failure.
Remove the separate pip, ensurepip, per-extra, and install-time quarantine
ladders. Keep orphan launcher restoration.

Verification: 717 targeted tests passed on native Windows ARM64, with
56 skipped. Ruff, diff checks, and the source-scoped compat check passed.
A disposable real Hermes install recovered deleted YAML and dotenv files,
then printed CLI help with exit 0. Its lock and stamp stayed unchanged.
The full suite and a release build were not run for this change.
2026-09-08 23:39:55 -04:00

229 lines
8.5 KiB
Python

"""The lockfile (versions + hashes, machine-written) and the installed-state
file (what is actually on this machine).
lock.json: {"schema": 1, "packages": {name: {"version": ..., "artifacts": {target: {"url": ..., "sha256": ...}}}}}
facts.json: {"schema": 1, "packages": {name: {"entry": ..., "version": ..., "env": ..., "stamp": ...}}}
A target's value is one {"url", "sha256"} object, or a LIST of them when the
package is split across several archives that must land in one directory
(llama.cpp's engine zip and its cudart zip: Windows resolves a DLL from the
loading executable's own directory). Both shapes read back as a list.
lock.json artifacts carry the RESOLVED url beside the hash: the lockfile is
the complete machine interface (nix reads it as pure data), and the python
url templates are consulted only at `pm lock --bump` time. The "any" target
key covers target-independent artifacts (npm's tarball).
facts.json env values hold {{store}} templates so a CI-built file adopts onto
any machine by substitution.
"""
from __future__ import annotations
import json
from pathlib import Path
SCHEMA = 1
STORE_TOKEN = "{{store}}"
def _read(path: Path, *, strict: bool = False) -> dict:
try:
text = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return {"schema": SCHEMA, "packages": {}}
except OSError:
if strict:
raise
return {"schema": SCHEMA, "packages": {}}
try:
data = json.loads(text)
if isinstance(data, dict) and data.get("schema") == SCHEMA and isinstance(data.get("packages"), dict):
return data
except ValueError:
pass
if strict:
raise ValueError(f"cannot read recorded package state: {path}")
if text.strip():
# An unparsable-but-nonempty state file is evidence, not garbage:
# the next _write would silently discard every installed-state
# record. Keep the bytes for post-mortem.
try:
from pm.paths import store_root
if path.parent == store_root() and (store_root().parent / "manifest.json").is_file():
import logging
logging.getLogger(__name__).warning("invalid shipped state file: %s", path)
else:
path.with_suffix(".corrupt").write_text(text, encoding="utf-8")
except OSError:
pass
return {"schema": SCHEMA, "packages": {}}
def _write(path: Path, data: dict) -> None:
from hermes_cli.runtime_state import _atomic_bytes
_atomic_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8"))
class Lockfile:
"""Read side of lock.json. Written only by `pm lock --bump` (cli)."""
def __init__(self, path: Path):
self.path = path
self._packages = _read(path)["packages"]
def version(self, name: str) -> str | None:
return (self._packages.get(name) or {}).get("version")
def artifacts(self, name: str, target: str) -> list[dict]:
"""Every archive this target needs, in extraction order. One
artifact and a list of them are the same thing here — the single
form is just the common case written short."""
artifacts = (self._packages.get(name) or {}).get("artifacts") or {}
found = artifacts.get(target)
if found is None:
found = artifacts.get("any")
if found is None:
return []
return list(found) if isinstance(found, list) else [found]
def names(self) -> list[str]:
return sorted(self._packages)
def set_pin(self, name: str, version: str, artifacts: dict[str, dict]) -> None:
self._packages[name] = {"version": version, "artifacts": artifacts}
def save(self) -> None:
_write(self.path, {"schema": SCHEMA, "packages": self._packages})
def termux_docker_digest() -> str:
"""The pinned termux-docker image digest (pm package `termux-docker`).
Single reader for every consumer -- the build/deb scripts, the builder
image script, and the release workflow -- so a lock-schema change lands
once.
"""
from pm.paths import lockfile_path
return Lockfile(lockfile_path()).version("termux-docker")
class Facts:
"""The installed-state file. Written only by pm."""
def __init__(self, path: Path, *, strict: bool = False):
self.path = path
self._packages = _read(path, strict=strict)["packages"]
def reload(self) -> None:
self._packages = _read(self.path)["packages"]
def get(self, name: str) -> dict | None:
return self._packages.get(name)
def installed(
self,
name: str,
expected_version: str | None,
store_root: Path,
expected_identity=None,
) -> bool:
"""``expected_identity`` is (target, tuple(artifact sha256s)) — the
identity the lock currently pins. When given, the fact must record
that exact identity: a same-version re-pin, a different target, or
a fact written before identity existed all count as NOT installed
and force a reinstall. ``None`` keeps the version+path check."""
fact = self._packages.get(name)
if not fact:
return False
if expected_version is not None and fact.get("version") != expected_version:
return False
if expected_identity is not None:
target, shas = expected_identity
if "target" not in fact or "artifacts" not in fact:
# Legacy fact: pre-dates digest-bound identity. Not
# vouchable — force one reinstall.
return False
if (fact["target"], tuple(fact["artifacts"])) != (target, tuple(shas)):
return False
return (store_root / fact["entry"]).exists()
def env_for(self, name: str, store_root: Path) -> dict:
fact = self._packages.get(name) or {}
return _resolve(fact.get("env", {}), store_root)
def _merge_and_write(self, name: str, fact: dict) -> None:
"""Read-modify-write against disk so concurrent installs of
different packages never clobber each other."""
on_disk = _read(self.path)["packages"]
for key, value in self._packages.items():
on_disk.setdefault(key, value)
self._packages = on_disk
self._packages[name] = fact
_write(self.path, {"schema": SCHEMA, "packages": self._packages})
def record(
self,
name: str,
version: str,
entry: str,
env: dict,
store_root: Path,
target: str | None = None,
artifacts: list[str] | None = None,
digest: str | None = None,
) -> None:
"""``target``/``artifacts`` record the identity this install came
from (work item 1); ``digest`` is the realized tree digest of the
published entry (work item 2). All three are additive — schema
stays 1 and older facts without them still read back."""
fact: dict = {
"entry": entry,
"version": version,
"env": _templatize(env, store_root),
}
if target is not None:
fact["target"] = target
if artifacts is not None:
fact["artifacts"] = list(artifacts)
if digest is not None:
fact["digest"] = digest
self._merge_and_write(name, fact)
def record_state(
self, name: str, stamp: str, extras: list[str], *,
environment: Path | None = None, resolved_lock: Path | None = None,
) -> None:
"""Commit a verified state package and its selected environment atomically."""
fact = {"stamp": stamp, "extras": extras}
if environment is not None:
fact["environment"] = str(environment.resolve())
if resolved_lock is not None:
fact["resolved_lock"] = str(resolved_lock.resolve())
self._merge_and_write(name, fact)
def entries_in_use(self) -> set[str]:
return {f["entry"] for f in self._packages.values() if "entry" in f}
def _templatize(env: dict, store_root: Path) -> dict:
root = str(store_root)
out = {}
for key, value in env.items():
if isinstance(value, list):
out[key] = [str(v).replace(root, STORE_TOKEN) for v in value]
else:
out[key] = str(value).replace(root, STORE_TOKEN)
return out
def _resolve(env: dict, store_root: Path) -> dict:
root = str(store_root)
out = {}
for key, value in env.items():
if isinstance(value, list):
out[key] = [str(v).replace(STORE_TOKEN, root) for v in value]
else:
out[key] = str(value).replace(STORE_TOKEN, root)
return out