587 lines
25 KiB
Python
587 lines
25 KiB
Python
"""ZIP-download fallback update path for ``hermes update`` (Windows with broken git): two-phase stage/commit directory swap, dirty-tree guard.
|
|
|
|
Split out of ``hermes_cli/update_cmd.py``; every moved name is re-imported there, so
|
|
``hermes_cli.update_cmd.<name>`` keeps resolving (and monkeypatching) as before.
|
|
Origin-internal helpers are imported lazily inside each function (no import cycle;
|
|
test patches on ``hermes_cli.update_cmd.<name>`` stay effective).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("hermes_cli.update_cmd")
|
|
|
|
|
|
def _atomic_replace_dir(src: str, dst: str) -> None:
|
|
"""Replace directory *dst* with *src* without leaving *dst* half-deleted.
|
|
|
|
Naive ``rmtree(dst); copytree(src, dst)`` has a destructive window: a
|
|
copy that fails partway (common on the Windows ZIP path, which only runs
|
|
because file I/O is already flaky) leaves the old tree gone and nothing
|
|
in its place (#49145: ``ui-tui/`` vanished and broke the TUI).
|
|
|
|
Now a thin alias over the two-phase helpers below (#76104); retained as
|
|
part of the ``hermes_cli.main`` re-export surface and the #49145 guard.
|
|
"""
|
|
_commit_staged_replacements([(_stage_replacement(src, dst), dst)])
|
|
|
|
|
|
def _stage_replacement(src: str, dst: str) -> str:
|
|
"""Copy *src* to a sibling staging path for *dst*; return the staging path.
|
|
|
|
Phase 1 of the two-phase replace. Handles both directories and plain
|
|
files. Touches nothing live, so a failure here leaves the whole install
|
|
untouched.
|
|
"""
|
|
staging = f"{dst}.hermes-update-staging"
|
|
backup = f"{dst}.hermes-update-old"
|
|
# A previous run may have died between "move dst aside" and "move staging
|
|
# in", leaving the backup as the ONLY copy. Restore it BEFORE clearing
|
|
# leftovers: deleting it and then failing to stage (disk exhaustion is
|
|
# likely here) would leave a hole with nothing to roll back to.
|
|
if not os.path.exists(dst) and os.path.exists(backup):
|
|
os.rename(backup, dst)
|
|
for leftover in (staging, backup):
|
|
if os.path.isdir(leftover):
|
|
shutil.rmtree(leftover, ignore_errors=True)
|
|
elif os.path.exists(leftover):
|
|
os.remove(leftover)
|
|
if os.path.isdir(src):
|
|
shutil.copytree(src, staging)
|
|
else:
|
|
shutil.copy2(src, staging)
|
|
return staging
|
|
|
|
|
|
def _discard_staged(staged) -> None:
|
|
"""Remove staging paths for entries that were never committed.
|
|
|
|
Otherwise a phase-1 failure (typically disk exhaustion) orphans one
|
|
staging copy per processed entry — up to a full second tree — and the
|
|
advised "re-run `hermes update`" retry fails harder with less free space.
|
|
"""
|
|
for staging, _dst in staged:
|
|
try:
|
|
if os.path.isdir(staging):
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
elif os.path.exists(staging):
|
|
os.remove(staging)
|
|
except OSError as exc: # best-effort cleanup, never fatal
|
|
logger.warning("could not remove staging path %s: %s", staging, exc)
|
|
|
|
|
|
def _commit_staged_replacements(staged) -> None:
|
|
"""Phase 2: swap every staged entry into place, rolling back all on failure.
|
|
|
|
``_atomic_replace_dir`` made each *individual* swap safe, but the ZIP
|
|
update loops over ~90 top-level entries and nothing made the loop atomic
|
|
*as a whole*: a partway failure left a mixed-version tree — every file
|
|
valid, the combination unbootable (#76104; also #76091, #63717).
|
|
|
|
Covers plain files too: the repo root holds 20 first-party modules, so a
|
|
files-only failure reproduces the same bug class. Every swap is an
|
|
``os.rename`` onto a just-moved-aside path — atomic on POSIX and NTFS —
|
|
so a file swap can't leave a half-written module the way ``copy2`` onto
|
|
a live path can.
|
|
|
|
Stage-all-then-swap-all shrinks the failure window from "a full tree
|
|
copy" to "N renames" and makes it recoverable: a failed swap restores
|
|
every entry already swapped, so the tree lands wholly new or wholly old.
|
|
"""
|
|
swapped: list[tuple[str, str]] = [] # (dst, backup) in swap order; "" = absent
|
|
try:
|
|
for staging, dst in staged:
|
|
backup = f"{dst}.hermes-update-old"
|
|
if os.path.exists(dst):
|
|
os.rename(dst, backup)
|
|
swapped.append((dst, backup))
|
|
else:
|
|
swapped.append((dst, ""))
|
|
os.rename(staging, dst)
|
|
except OSError:
|
|
# Undo every swap already made so the install stays self-consistent.
|
|
for dst, backup in reversed(swapped):
|
|
try:
|
|
if os.path.isdir(dst):
|
|
shutil.rmtree(dst, ignore_errors=True)
|
|
elif os.path.exists(dst):
|
|
os.remove(dst)
|
|
if backup and os.path.exists(backup):
|
|
os.rename(backup, dst)
|
|
except OSError as exc:
|
|
# Keep restoring the rest — a silent failure here is the one
|
|
# thing that turns a recoverable rollback into a mixed tree,
|
|
# so say so rather than swallowing it.
|
|
logger.warning("rollback failed for %s: %s", dst, exc)
|
|
raise
|
|
# All swaps succeeded — drop the backups (best-effort, never fatal).
|
|
for _dst, backup in swapped:
|
|
if backup and os.path.isdir(backup):
|
|
shutil.rmtree(backup, ignore_errors=True)
|
|
elif backup and os.path.exists(backup):
|
|
try:
|
|
os.remove(backup)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _zip_overlay_block_reason(
|
|
root: Path, *, ignore_staging_artifacts: bool = False
|
|
) -> Optional[str]:
|
|
"""Why overlaying a ZIP onto ``root`` would destroy work, or None if safe.
|
|
|
|
The ZIP path swaps every top-level entry (minus a tiny preserve set) and
|
|
deletes the backups, so uncommitted edits and untracked files are gone.
|
|
Fails closed when git status cannot run (#87304).
|
|
|
|
``ignore_staging_artifacts`` is for the pre-swap re-check: phase 1 leaves
|
|
``*.hermes-update-staging`` siblings that git reports as untracked; they
|
|
are our own artifacts, and without the filter the re-check always refuses.
|
|
"""
|
|
if not (root / ".git").exists():
|
|
return None
|
|
git_cmd = ["git"]
|
|
if sys.platform == "win32":
|
|
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
|
result = subprocess.run(
|
|
# -uall: a user-level ``status.showUntrackedFiles = no`` must not
|
|
# blind this guard. --ignored=matching: gitignored files are still
|
|
# USER DATA the overlay would delete (#87392); ``matching`` reports an
|
|
# ignored dir as one ``dir/`` line (cheaper, same verdict below).
|
|
# NOTE: ``--ignored=all`` is NOT a valid git mode — exits 128 and
|
|
# would fail-close every ZIP update.
|
|
git_cmd + ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
|
suffix = f" ({detail[0]})" if detail else ""
|
|
return f"could not check the working tree{suffix}"
|
|
lines = [line for line in (result.stdout or "").splitlines() if line.strip()]
|
|
# --ignored=all reports the ZIP path's own preserved entries (venv,
|
|
# node_modules are gitignored on every normal install). The swap never
|
|
# touches those top-level entries, so they must not turn into a false
|
|
# dirty-tree refusal. Everything else — including ignored files — blocks.
|
|
lines = [line for line in lines if not _is_zip_preserved_entry_status_line(line)]
|
|
if ignore_staging_artifacts:
|
|
lines = [
|
|
line for line in lines if not _is_zip_staging_artifact_status_line(line)
|
|
]
|
|
if lines:
|
|
return "the working tree has uncommitted changes or untracked files"
|
|
return None
|
|
|
|
|
|
_ZIP_STAGING_ARTIFACT_SUFFIXES = (".hermes-update-staging", ".hermes-update-old")
|
|
|
|
|
|
# Single source of truth for the top-level entries the ZIP swap preserves —
|
|
# consumed by both the dirty-tree filter below and _update_via_zip's swap loop.
|
|
_ZIP_PRESERVED_TOP_LEVEL = {"venv", "node_modules", ".git", ".env"}
|
|
|
|
|
|
def _is_zip_preserved_entry_status_line(line: str) -> bool:
|
|
"""True when every path on a porcelain status line sits under a top-level
|
|
entry the ZIP swap preserves.
|
|
|
|
The ``" -> "`` split applies ONLY to rename/copy codes (R/C): porcelain
|
|
v1 doesn't quote plain filenames with spaces, so an ignored file named
|
|
``venv -> node_modules`` on a ``!!``/``??`` line is ONE path — splitting
|
|
would fail-open into the destructive swap. Requiring EVERY path preserved
|
|
keeps renames out of a preserved dir (``R venv/x -> src/x``) blocking.
|
|
"""
|
|
status, payload = (line[:2], line[3:]) if len(line) >= 3 else ("", line)
|
|
is_rename = any(code in "RC" for code in status)
|
|
paths = payload.split(" -> ") if is_rename else [payload]
|
|
for path in paths:
|
|
top_level = (
|
|
path.strip().strip('"').replace("\\", "/").rstrip("/").split("/", 1)[0]
|
|
)
|
|
if top_level not in _ZIP_PRESERVED_TOP_LEVEL:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _is_zip_staging_artifact_status_line(line: str) -> bool:
|
|
"""True when a porcelain status line is our own two-phase-swap artifact."""
|
|
payload = line[3:] if len(line) >= 3 else line
|
|
top_level = (
|
|
payload.strip().strip('"').replace("\\", "/").rstrip("/").split("/", 1)[0]
|
|
)
|
|
return top_level.endswith(_ZIP_STAGING_ARTIFACT_SUFFIXES)
|
|
|
|
|
|
def _abort_zip_update_if_dirty_tree() -> None:
|
|
"""Refuse to overlay a ZIP onto a dirty git checkout (#87304)."""
|
|
from hermes_cli.update_cmd import _m
|
|
reason = _zip_overlay_block_reason(_m().PROJECT_ROOT)
|
|
if reason is None:
|
|
return
|
|
print(f"✗ ZIP fallback refused: {reason}.")
|
|
print(
|
|
" Overlaying the ZIP would overwrite uncommitted edits and permanently "
|
|
"delete untracked files."
|
|
)
|
|
print(" Stash or commit your changes, then rerun `hermes update`.")
|
|
print(" To inspect: git status --porcelain")
|
|
_m().sys.exit(1)
|
|
|
|
|
|
def _download_and_swap_zip(branch: str, zip_url: str) -> None:
|
|
"""Download the source ZIP for *branch* and two-phase swap it into the checkout.
|
|
|
|
Exits the process (``sys.exit(1)``) on any failure; the two-phase replace
|
|
guarantees the install is either fully updated or fully rolled back.
|
|
"""
|
|
from hermes_cli.update_cmd import _m
|
|
|
|
import tempfile
|
|
import zipfile
|
|
from urllib.request import urlretrieve
|
|
|
|
print("→ Downloading latest version...")
|
|
tmp_dir = tempfile.mkdtemp(prefix="hermes-update-")
|
|
try:
|
|
zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
|
|
urlretrieve(zip_url, zip_path)
|
|
|
|
print("→ Extracting...")
|
|
import stat as _stat
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
# Reject zip-slip (path traversal) AND symlink members: a
|
|
# hermes-agent source ZIP never legitimately contains symlinks,
|
|
# and a compromised mirror could use them to plant files anywhere.
|
|
tmp_dir_real = os.path.realpath(tmp_dir)
|
|
for member in zf.infolist():
|
|
member_path = os.path.realpath(os.path.join(tmp_dir, member.filename))
|
|
if (
|
|
not member_path.startswith(tmp_dir_real + os.sep)
|
|
and member_path != tmp_dir_real
|
|
):
|
|
raise ValueError(
|
|
f"Zip-slip detected: {member.filename} escapes extraction directory"
|
|
)
|
|
# Unix mode lives in the upper 16 bits of external_attr;
|
|
# mask to the file-type bits.
|
|
mode = (member.external_attr >> 16) & 0o170000
|
|
if _stat.S_ISLNK(mode):
|
|
raise ValueError(
|
|
f"ZIP contains unsupported symlink member: {member.filename}"
|
|
)
|
|
zf.extractall(tmp_dir)
|
|
|
|
# GitHub ZIPs extract to hermes-agent-<branch>/
|
|
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
|
|
if not os.path.isdir(extracted):
|
|
for d in os.listdir(tmp_dir):
|
|
candidate = os.path.join(tmp_dir, d)
|
|
if os.path.isdir(candidate) and d != "__MACOSX":
|
|
extracted = candidate
|
|
break
|
|
|
|
preserve = _ZIP_PRESERVED_TOP_LEVEL
|
|
entries = [i for i in os.listdir(extracted) if i not in preserve]
|
|
|
|
# Two-phase replace (#76104): phase 1 stages every entry (dirs AND
|
|
# top-level files — the repo root holds 20 first-party modules) beside
|
|
# its target; phase 2 swaps all in with same-filesystem renames and
|
|
# rolls back on any failure. One-at-a-time replacement left `agent/`
|
|
# new and `tools/` stale on interruption: all files valid, tree
|
|
# unbootable. Staging costs one extra tree copy — check space up front.
|
|
need = sum(
|
|
os.path.getsize(os.path.join(dirpath, f))
|
|
for entry in entries
|
|
for dirpath, _dirs, files in os.walk(os.path.join(extracted, entry))
|
|
for f in files
|
|
) + sum(
|
|
os.path.getsize(os.path.join(extracted, e))
|
|
for e in entries
|
|
if os.path.isfile(os.path.join(extracted, e))
|
|
)
|
|
# Swaps are renames, so only the staging copy is new: require it plus
|
|
# 20% headroom, not 2x — which would block updates on exactly the
|
|
# space-constrained machines most likely to hit this path.
|
|
required = int(need * 1.2)
|
|
free = shutil.disk_usage(str(_m().PROJECT_ROOT)).free
|
|
if free < required:
|
|
raise RuntimeError(
|
|
f"not enough free disk space to stage the update safely "
|
|
f"(need ~{required // (1024 * 1024)} MB, have "
|
|
f"{free // (1024 * 1024)} MB)"
|
|
)
|
|
|
|
staged: list[tuple[str, str]] = []
|
|
try:
|
|
for item in entries:
|
|
src = os.path.join(extracted, item)
|
|
dst = os.path.join(str(_m().PROJECT_ROOT), item)
|
|
staged.append((_stage_replacement(src, dst), dst))
|
|
# #70337/#87331: the source ZIP lacks apps/desktop/release/
|
|
# (the BUILT desktop app); swapping `apps` without it deletes
|
|
# the build and breaks the shortcut. Graft the live release
|
|
# dir into the staged copy BEFORE the swap.
|
|
if item == "apps":
|
|
live_release = os.path.join(dst, "desktop", "release")
|
|
staged_release = os.path.join(
|
|
staged[-1][0], "desktop", "release"
|
|
)
|
|
if os.path.isdir(live_release) and not os.path.exists(
|
|
staged_release
|
|
):
|
|
os.makedirs(os.path.dirname(staged_release), exist_ok=True)
|
|
shutil.copytree(live_release, staged_release)
|
|
except Exception:
|
|
# Nothing is live yet; drop the partial staging copies so a retry
|
|
# starts from the same free space this attempt did.
|
|
_discard_staged(staged)
|
|
raise
|
|
|
|
try:
|
|
# Re-check right before the swap (#87304 TOCTOU): download +
|
|
# extract + staging can take minutes, and work created meanwhile
|
|
# would be destroyed. Our own staging siblings are filtered out.
|
|
recheck_reason = _zip_overlay_block_reason(
|
|
_m().PROJECT_ROOT, ignore_staging_artifacts=True
|
|
)
|
|
if recheck_reason is not None:
|
|
_discard_staged(staged)
|
|
print(f"✗ ZIP fallback aborted before the swap: {recheck_reason}.")
|
|
print(
|
|
" Files appeared in the checkout while the update was "
|
|
"downloading; committing the swap would delete them."
|
|
)
|
|
print(" Stash or commit your changes, then rerun `hermes update`.")
|
|
_m().sys.exit(1)
|
|
_commit_staged_replacements(staged)
|
|
except Exception:
|
|
# Rollback restored the swapped entries, but staging copies for
|
|
# the rest (possibly most of a tree) remain. Drop them, or the
|
|
# retry's up-front free-space check (which runs BEFORE per-entry
|
|
# leftover cleanup) fails on our litter. Safe post-rollback:
|
|
# _discard_staged skips paths that no longer exist.
|
|
_discard_staged(staged)
|
|
raise
|
|
update_count = len(staged)
|
|
|
|
print(f"✓ Updated {update_count} items from ZIP")
|
|
|
|
except Exception as e:
|
|
print(f"✗ ZIP update failed: {e}")
|
|
# The two-phase replace either commits every entry or rolls them all
|
|
# back, so a failure here does not leave a mixed-version tree — don't
|
|
# scare the user toward a reinstall they don't need.
|
|
print(" Your existing install was left in place.")
|
|
print(
|
|
" Re-run `hermes update` to retry; if the agent won't start, "
|
|
"reinstall from https://hermes-agent.nousresearch.com"
|
|
)
|
|
_m().sys.exit(1)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
|
|
"""Reinstall Python deps (uv preferred, pip fallback) and re-arm active tool deps."""
|
|
from hermes_cli.update_cmd import (
|
|
_ensure_uv_for_termux,
|
|
_ensure_venv_pip,
|
|
_m,
|
|
_refuse_update_for_contended_shims,
|
|
_shim_quarantine_error_type,
|
|
)
|
|
|
|
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
|
|
|
# Keep managed uv current — runs `uv self update` if we already have one.
|
|
update_managed_uv()
|
|
|
|
uv_bin = ensure_uv()
|
|
|
|
pip_cmd = [_m().sys.executable, "-m", "pip"]
|
|
if not uv_bin:
|
|
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
|
if uv_bin:
|
|
# Same third-party UV-env isolation as the main update path (#83914):
|
|
# a user-level UV_PYTHON_INSTALL_DIR / UV_PYTHON from unrelated
|
|
# software must not steer which interpreter uv resolves here.
|
|
from hermes_cli.managed_uv import managed_python_env
|
|
|
|
uv_env = managed_python_env()
|
|
uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
|
|
if _m()._is_termux_env(uv_env):
|
|
uv_env.pop("PYTHONPATH", None)
|
|
uv_env.pop("PYTHONHOME", None)
|
|
try:
|
|
_m()._install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
|
|
except _shim_quarantine_error_type() as _sqe:
|
|
# #87331: this runs inside the ZIP-fallback error handler, so the
|
|
# boundary except clause in cmd_update cannot catch it — refuse
|
|
# here with the same defer-via-marker contract.
|
|
_refuse_update_for_contended_shims(_sqe)
|
|
else:
|
|
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
|
|
_ensure_venv_pip(pip_cmd, _m().sys.executable)
|
|
_m()._install_python_dependencies_with_optional_fallback(pip_cmd)
|
|
|
|
install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd
|
|
install_env = uv_env if uv_bin else None
|
|
_m()._restore_active_tool_dependencies(
|
|
active_tool_dependencies,
|
|
install_prefix,
|
|
env=install_env,
|
|
)
|
|
|
|
# ZIP path parity: heal the active memory provider's bridge packages
|
|
# after the dependency reinstall, same as the git-pull path (#53272,
|
|
# #70636).
|
|
_m()._refresh_active_memory_provider_dependencies()
|
|
|
|
|
|
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> bool:
|
|
"""Update Hermes Agent by downloading a ZIP archive.
|
|
|
|
Used on Windows when git file I/O is broken (antivirus, NTFS filter
|
|
drivers causing 'Invalid argument' errors on file creation).
|
|
|
|
Returns ``False`` when a Desktop rebuild ran and failed; ``True`` otherwise.
|
|
"""
|
|
from hermes_cli.update_cmd import (
|
|
_finish_dashboard_update_cleanup,
|
|
_m,
|
|
_print_bundled_skills_sync_report,
|
|
_print_curator_first_run_notice,
|
|
_print_curator_recent_run_notice,
|
|
_print_update_summary,
|
|
_read_project_version,
|
|
_rebuild_desktop_after_update,
|
|
_sweep_bytecode_after_update,
|
|
_update_node_dependencies,
|
|
_validate_critical_modules_import,
|
|
_verify_and_restore_state_dbs_post_update,
|
|
)
|
|
active_tool_dependencies = _m()._capture_active_tool_dependencies()
|
|
|
|
# Snapshot the pre-update version before files are replaced so the
|
|
# completion line can report the transition (prime-agent#630 port).
|
|
pre_update_version = _read_project_version()
|
|
|
|
# The static GitHub archive is fine for "main" but would silently ignore
|
|
# --branch — the exact silent-divergence bug --branch was added to
|
|
# prevent. Refuse rather than lie.
|
|
branch = _m()._resolve_update_branch(args)
|
|
if branch != "main":
|
|
print(
|
|
f"✗ --branch={branch} is not supported on the Windows ZIP-fallback "
|
|
"update path."
|
|
)
|
|
print(
|
|
" This path runs when git file I/O is broken on the system. "
|
|
"Either resolve the git-side breakage (typically an antivirus "
|
|
"or NTFS filter holding files open) and rerun `hermes update "
|
|
f"--branch {branch}`, or update against main with `hermes update`."
|
|
)
|
|
_m().sys.exit(1)
|
|
_abort_zip_update_if_dirty_tree()
|
|
zip_url = (
|
|
f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
|
|
)
|
|
|
|
_download_and_swap_zip(branch, zip_url)
|
|
|
|
_sweep_bytecode_after_update(branch)
|
|
|
|
# Reinstall Python deps: prefer .[all]; if one extra breaks, keep base
|
|
# deps and retry the remaining extras individually so working
|
|
# capabilities aren't silently stripped. Self-lock deferral (#86735): the
|
|
# code swap is committed; defer only the dependency sync when this
|
|
# process holds a native extension the sync must rewrite.
|
|
_m()._abort_dependency_sync_if_self_locked()
|
|
print("→ Updating Python dependencies...")
|
|
|
|
_reinstall_python_deps_after_zip(active_tool_dependencies)
|
|
|
|
# Verify the tree actually imports (catches the parse-OK-but-skewed tree
|
|
# an interrupted copy leaves). Placed *after* the dependency reinstall so
|
|
# a genuinely-new third-party requirement isn't misreported as a partial
|
|
# copy. No SHA to roll back to here — surface a concrete recovery step
|
|
# instead of reporting success over a bricked install.
|
|
import_ok, failing_module, import_error = _validate_critical_modules_import(
|
|
_m().PROJECT_ROOT
|
|
)
|
|
if not import_ok:
|
|
print()
|
|
print("✗ Update left the install in an unimportable state:")
|
|
print(f" {failing_module}: {import_error}")
|
|
print()
|
|
print(" This usually means the copy was interrupted partway through.")
|
|
print(" Re-run `hermes update` to complete it.")
|
|
_m().sys.exit(1)
|
|
|
|
node_failures = _update_node_dependencies()
|
|
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
|
|
desktop_build_ok = _rebuild_desktop_after_update(
|
|
_m().PROJECT_ROOT / "apps" / "desktop",
|
|
had_desktop_app_before_update=had_desktop_app_before_update,
|
|
)
|
|
|
|
try:
|
|
print("→ Syncing bundled skills...")
|
|
_print_bundled_skills_sync_report()
|
|
except Exception:
|
|
pass
|
|
|
|
# Seed the model-catalog disk cache from the freshly-unpacked checkout
|
|
# (same rationale as the git-pull path in _cmd_update_impl). Non-fatal.
|
|
try:
|
|
from hermes_cli.model_catalog import seed_cache_from_checkout
|
|
|
|
if seed_cache_from_checkout(_m().PROJECT_ROOT):
|
|
print(" ✓ Model catalog cache refreshed from checkout")
|
|
except Exception as e:
|
|
logger.debug("Model catalog seed during zip update failed: %s", e)
|
|
|
|
# Post-update state.db integrity guard (#68474, #97994): root home AND
|
|
# every sibling profile, each auto-restored from its own snapshot.
|
|
try:
|
|
_verify_and_restore_state_dbs_post_update()
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Post-update state.db integrity check (zip path) failed: %s", exc
|
|
)
|
|
|
|
update_complete = _print_update_summary(
|
|
node_failures=node_failures,
|
|
desktop_build_ok=desktop_build_ok,
|
|
pre_update_version=pre_update_version,
|
|
)
|
|
try:
|
|
_print_curator_first_run_notice()
|
|
except Exception as e:
|
|
logger.debug("Curator first-run notice failed: %s", e)
|
|
try:
|
|
_print_curator_recent_run_notice()
|
|
except Exception as e:
|
|
logger.debug("Curator recent-run notice failed: %s", e)
|
|
# Don't stop a working dashboard when the Node refresh failed — see the
|
|
# git-update path for rationale (#30271).
|
|
_finish_dashboard_update_cleanup(node_failures)
|
|
try:
|
|
from hermes_cli.update_receipt import finalize_update_receipt
|
|
|
|
finalize_update_receipt(
|
|
"success" if update_complete and not node_failures else "partial"
|
|
)
|
|
except Exception as _receipt_exc:
|
|
logger.debug("Update receipt finalize (zip path) failed: %s", _receipt_exc)
|
|
return update_complete
|