Files
hermes-agent/hermes_cli/version_info.py
ethernet 7038ba8f46 fix(desktop): show how far past the release an install is, not its commit
About showed a bare "v0.21.5" for a checkout 1913 commits past the tag
(`hermes --version`: "v0.21.5+1913.gf83a9e9"), because /api/health only
reports the base release; the statusbar showed the version plus the exact
commit beside it.

VersionInfo.display_version is the short `<release>+<distance>` form and
/api/health carries it as displayVersion (older backends fall back to the
bare release). One renderer formatter, shortVersion, turns any version into
that form, and About, the updates overlay, the statusbar and the command
palette all label through it: "v0.21.5+1913". The full commit stays in the
statusbar tooltip and the expanded version details.
2026-09-24 14:23:48 -04:00

323 lines
12 KiB
Python

"""Canonical runtime identity for Hermes.
Resolution order:
1. Install stamp (``install-stamp.json``) — written at build time by
``scripts/write_install_stamp.py`` for every packager (Docker, Nix, and
the desktop app). The stamp is authoritative
for packaged builds.
2. Live git — for unstamped source/dev installs with a ``.git`` directory.
3. Unknown — no stamp and no git. The provenance is unknown.
"""
from __future__ import annotations
import json
import subprocess
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, cast
from hermes_cli.steward import UPDATE_MECHANISMS
from hermes_cli.update_channel import STABLE_TAG_RE
@dataclass(frozen=True)
class VersionInfo:
base_version: str
derived_version: str
distance: int | None
commit: str | None
branch: str | None
source: Literal["build", "commit-build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"]
dirty: bool = False
commit_date: int | None = None
distribution: Literal["docker", "nix", "desktop-app"] | None = None
@property
def display_version(self) -> str:
"""``<base>+<distance>``: the short form surfaces label a version by.
The commit is shown beside it where there is room, never inside it."""
return _derived_version(self.base_version, self.distance)
def _derived_version(
base_version: str,
distance: int | None,
dirty: bool = False,
short_commit: str | None = None,
) -> str:
if distance and distance > 0:
suffix = f"{distance}.g{short_commit}" if short_commit else str(distance)
return f"{base_version}+{suffix}{'.dirty' if dirty and short_commit else ''}"
if dirty and distance is None:
return f"{base_version}+?"
return base_version
def _run_git(repo_dir: Path, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args], capture_output=True, text=True, timeout=3, cwd=str(repo_dir)
)
except (OSError, subprocess.SubprocessError):
return None
value = (result.stdout or "").strip()
return value if result.returncode == 0 and value else None
def _resolve_repo_dir() -> Path | None:
"""Use the executing checkout before a profile's optional clone."""
repo_dir = Path(__file__).parent.parent.resolve()
if (repo_dir / ".git").exists():
return repo_dir
# The PROCESS home: this is the running code's identity and is cached
# process-wide, so a profile's context-local override must not pick it.
from hermes_constants import get_process_hermes_home
candidate = get_process_hermes_home() / "hermes-agent"
if (candidate / ".git").exists():
return candidate
return None
def _parse_nonnegative(value: str | None) -> int | None:
try:
parsed = int(value or "")
except ValueError:
return None
return parsed if parsed >= 0 else None
def _calver_release_version(repo_dir: Path) -> tuple[str, int] | None:
"""The version the nearest CalVer release shipped, and the commits since it.
Releases before semver tags existed are tagged ``vYYYY.M.D`` only; the
version users actually run is in that tag's pyproject. Without this, a
checkout past such a release would compare as "unknown" against plugins'
``requires_hermes``.
"""
described = _run_git(repo_dir, "describe", "--tags", "--long", "--match", "v2[0-9][0-9][0-9].*", "HEAD")
if not described:
return None
tag, count, _ = described.rsplit("-", 2)
distance = _parse_nonnegative(count)
try:
project = tomllib.loads(_run_git(repo_dir, "show", f"{tag}:pyproject.toml") or "").get("project", {})
except tomllib.TOMLDecodeError:
return None
version = project.get("version")
if distance is None or not isinstance(version, str) or not STABLE_TAG_RE.fullmatch(f"v{version}"):
return None
return version, distance
# --- Install stamp reader ---------------------------------------------------
def _resolve_stamp_file() -> Path | None:
"""The executing tree's stamp (pm.paths.install_stamp_path owns the location)."""
from pm.paths import install_stamp_path, repo_root
p = install_stamp_path(repo_root())
return p if p.is_file() else None
def _stamp_version_info() -> VersionInfo | None:
"""Read provenance from a build-time install stamp."""
stamp_file = _resolve_stamp_file()
if stamp_file is None:
return None
try:
raw = stamp_file.read_text(encoding="utf-8-sig")
data = json.loads(raw)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or "commit" not in data:
return None
# updateMechanism is required in every stamp. A stamp without it means
# the writing build lane must be fixed, not tolerated.
if data.get("updateMechanism") not in UPDATE_MECHANISMS:
raise RuntimeError(
f"install-stamp.json at {stamp_file} is missing a valid "
f"'updateMechanism' (one of {', '.join(UPDATE_MECHANISMS)}). The "
"build lane that wrote this stamp must pass --update-mechanism to "
"scripts/write_install_stamp.py (or bake the field directly)."
)
# A light artifact ships no Python runtime. Reading a light stamp from
# a Python process means the artifact was mispackaged.
if data.get("payload") == "light":
raise RuntimeError(
f"install-stamp.json at {stamp_file} marks this artifact as 'light' "
"(no agent runtime). No Python process can legitimately run from a "
"light artifact — this build is mispackaged."
)
commit = data.get("commit") or None
if not commit or set(commit) == {"0"}:
# All-zero placeholder = fallback stamp, not real provenance.
return None
stamp_source = str(data.get("source") or "")
if stamp_source == "git" and (stamp_file.parent / ".git").exists():
live_commit = _run_git(stamp_file.parent, "rev-parse", "HEAD")
if live_commit and live_commit != commit:
return None
base_version = data.get("baseVersion") or "unknown"
display_version = data.get("displayVersion") or base_version
distance = data.get("distance")
if isinstance(distance, str):
distance = _parse_nonnegative(distance)
# ``source`` describes build provenance, while ``distribution`` identifies
# the package form users installed. Keep both facts intact for support.
source = (
cast(Literal["build", "commit-build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"], stamp_source)
if stamp_source in {"commit-build", "ci", "docker", "fallback", "git", "local", "nix"}
else "build"
)
distribution = data.get("distribution")
if distribution not in {"docker", "nix", "desktop-app"}:
distribution = None
commit_date = data.get("commitDate")
if not isinstance(commit_date, int):
commit_date = None
return VersionInfo(
base_version,
display_version,
distance if isinstance(distance, int) else None,
commit,
data.get("branch") or None,
source,
bool(data.get("dirty")),
commit_date,
distribution,
)
# --- Git provenance (source/dev installs) -----------------------------------
def _git_version_info(repo_dir: Path, *, include_untracked: bool = False) -> VersionInfo:
commit = _run_git(repo_dir, "rev-parse", "HEAD")
# A detached HEAD has no branch. Leave the field None: every formatter
# already prints the commit separately and handles a missing branch.
branch = _run_git(repo_dir, "branch", "--show-current")
commit_date_raw = _run_git(repo_dir, "log", "-1", "--format=%ct", "HEAD")
commit_date: int | None = None
if commit_date_raw and commit_date_raw.isdigit():
commit_date = int(commit_date_raw)
try:
# -uno: skip the untracked-file scan. This runs on the startup-banner
# path, and a full working-tree walk costs real time on large or cold
# checkouts. Same semantics as write_install_stamp.py.
status_command = ["git", "status", "--porcelain"]
if not include_untracked:
status_command.append("-uno")
dirty_result = subprocess.run(
status_command,
capture_output=True,
text=True,
timeout=3,
cwd=str(repo_dir),
)
dirty = dirty_result.returncode == 0 and bool((dirty_result.stdout or "").strip())
except (OSError, subprocess.SubprocessError):
dirty = False
tags = _run_git(repo_dir, "tag", "--merged", "HEAD", "--list", "v[0-9]*")
releases = [
tag[1:]
for tag in (tags or "").splitlines()
if STABLE_TAG_RE.fullmatch(tag)
]
base_version = (
max(releases, key=lambda value: tuple(int(part) for part in value.split(".")))
if releases else "unknown"
)
distance = _parse_nonnegative(
_run_git(repo_dir, "rev-list", "--count", f"v{base_version}..HEAD")
) if releases else None
if not releases:
base_version, distance = _calver_release_version(repo_dir) or ("unknown", None)
short_commit = _run_git(repo_dir, "rev-parse", "--short=7", "HEAD")
if base_version == "unknown" and short_commit:
display_version = f"git.{short_commit}{'.dirty' if dirty else ''}"
else:
display_version = _derived_version(base_version, distance, dirty, short_commit)
return VersionInfo(
base_version, display_version, distance, commit, branch, "git", dirty, commit_date
)
# --- Cache + public API -----------------------------------------------------
_cached_version_info: VersionInfo | None = None
def _reset_version_info_cache() -> None:
"""Test-only cache reset."""
global _cached_version_info
_cached_version_info = None
def get_version_info() -> VersionInfo:
"""Return cached provenance from install stamp, git, or unknown."""
global _cached_version_info
if _cached_version_info is not None:
return _cached_version_info
# 1. Install stamp (packaged builds: Docker, Nix)
# A malformed stamp (missing/illegal updateMechanism, mispackaged "light"
# payload) raises RuntimeError — the build lane that wrote it must be
# fixed, but a crashing CLI is the wrong failure mode for every uncached
# caller. Degrade to "unknown" and let the authoring lane's own tests
# surface the malformed stamp.
try:
info = _stamp_version_info()
except RuntimeError:
info = None
# 2. Live git (source/dev installs)
if info is None:
repo_dir = _resolve_repo_dir()
if repo_dir is not None:
info = _git_version_info(repo_dir)
# 3. Unknown — no stamp, no git
if info is None:
info = VersionInfo("unknown", "unknown", None, None, None, "unknown")
_cached_version_info = info
return info
def get_code_identity(refresh: bool = False) -> dict:
"""Return the running install's code identity as a flat dict.
Shape: ``{"sha": full sha | None, "short_sha": str | None, "version":
base package version | None, "source": str}`` — what the update
receipt, runtime inventory, and gateway status stamping consume.
Backed by :func:`get_version_info` (install stamp first, live git
second, unknown third), so every consumer shares one resolution
policy. Returned dicts are fresh copies, never the shared cache.
``refresh=True`` drops the per-process cache first — the updater uses
it to re-read identity after swapping the checkout out from under the
running process.
"""
global _cached_version_info
if refresh:
_cached_version_info = None
info = get_version_info()
return {
"sha": info.commit,
"short_sha": info.commit[:8] if info.commit else None,
"version": info.base_version,
"source": info.source,
}