Files
hermes-agent/pm/lock.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

307 lines
12 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
import os
import re
from copy import deepcopy
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 pm.filesystem import durable_write_bytes
durable_write_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8"))
class StaleLockRow(RuntimeError):
"""A concurrent editor changed a row that this writer plans to publish."""
class Lockfile:
"""Snapshot of lock.json with conflict-checked publication of changed pins."""
def __init__(self, path: Path):
self.path = Path(path).resolve()
self._packages = _read(self.path)["packages"]
self._base = deepcopy(self._packages)
self._touched: set[str] = set()
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 pinned_artifacts(self, name: str) -> dict:
"""Return the complete pin table, including targets without discovery."""
return deepcopy((self._packages.get(name) or {}).get("artifacts") or {})
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": deepcopy(artifacts)}
self._touched.add(name)
def save(self) -> None:
"""Merge touched rows under the file's lock, or leave all rows unchanged."""
from pm.filesystem import lock_fd
self.path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self.path.with_name(f".{self.path.name}.lock"), os.O_CREAT | os.O_RDWR, 0o600)
try:
lock_fd(fd, wait=True)
current = _read(self.path, strict=True)["packages"]
updated = dict(current)
for name in self._touched:
intended = self._packages[name]
if current.get(name) != self._base.get(name) and current.get(name) != intended:
raise StaleLockRow(f"{name} changed in {self.path}; read the lockfile again and retry")
updated[name] = intended
if updated != current or not self.path.exists():
_write(self.path, {"schema": SCHEMA, "packages": updated})
self._packages = updated
self._base = deepcopy(updated)
self._touched.clear()
finally:
os.close(fd)
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, strict=True)["packages"]
def get(self, name: str) -> dict | None:
return self._packages.get(name)
def refresh_digests(self, store_root: Path) -> int:
"""Publish all tool digests after packaging finishes changing their bytes."""
from pm.store import tree_digest
packages = _read(self.path, strict=True)["packages"]
root = store_root.resolve()
count = 0
for name, fact in packages.items():
if not isinstance(fact, dict):
raise ValueError(f"invalid payload fact: {name}")
if "entry" not in fact and "stamp" in fact:
continue
entry_name = fact.get("entry")
artifacts = fact.get("artifacts")
if (not isinstance(entry_name, str) or entry_name in ("", ".", "..")
or any(c in entry_name for c in "/\\:")
or not isinstance(fact.get("version"), str) or not fact["version"]
or not isinstance(fact.get("target"), str) or not fact["target"]
or not isinstance(artifacts, list) or not artifacts
or any(not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha)
for sha in artifacts)):
raise ValueError(f"incomplete payload tool: {name}")
entry = root / entry_name
if not entry.is_dir() or not entry.resolve().is_relative_to(root):
raise ValueError(f"incomplete payload tool: {name}")
fact["digest"] = tree_digest(entry)
count += 1
if not count:
raise ValueError(f"payload facts carry no tool entries: {self.path}")
_write(self.path, {"schema": SCHEMA, "packages": packages})
self._packages = packages
return count
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 or "entry" not in fact:
# State facts (``record_state``: venv stamp + extras) share this
# file with tool facts but own no store entry.
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, strict=True)["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 retain(self, names: set[str]) -> None:
"""Drop unselected package facts from an exclusively owned staged store."""
packages = _read(self.path, strict=True)["packages"]
retained = {name: fact for name, fact in packages.items() if name in names}
if retained != packages:
_write(self.path, {"schema": SCHEMA, "packages": retained})
self._packages = retained
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