mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
When the Webapp build in `hermes update` failed, the previous bundle stayed on disk and the gateway went on serving it as current: `hermes webapp --status` said "Serving", the launch link opened it, and browsers loaded a renderer built for the code before the update against the updated backend. The gateway only checked that the bundle's index.html exists. The update now marks the bundle it could not rebuild out of date, with a file inside the bundle folder, and keeps the bundle itself. While the mark is there the gateway reports the Webapp unavailable with "The Webapp is not built for this version of Hermes: hermes update could not rebuild it. Run hermes webapp to rebuild it.", mints no launch link, and answers the page with that message (503) instead of the old renderer. The next build that succeeds (`hermes webapp`, Web Access Start or the next update), or that finds the bundle current, removes the mark and the running gateway serves the Webapp again without a restart. Publishing a build replaces the whole folder, and the build receipt's output hash covers the mark, so a marked bundle is never reused as current. `--skip-build` writes nothing and leaves the mark. With no bundle built there is nothing to mark. The desktop guide says what happens when the update's build fails.
313 lines
16 KiB
Python
313 lines
16 KiB
Python
"""Source launch/update composition over the shared JavaScript builders."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def source_product_current(project_root: Path, product: str, out: Path) -> bool:
|
|
"""Read the compiler's receipt without acquiring tools or dependencies."""
|
|
from pm import env_for, installed_package
|
|
|
|
installed = installed_package("node")
|
|
if installed is None or installed.binary is None:
|
|
return False # Only PM's Node may run the receipt reader; never the user's PATH copy.
|
|
node, env = str(installed.binary), env_for("node")
|
|
try:
|
|
result = subprocess.run(
|
|
[node, str(project_root / "scripts/build/freshness.mjs"),
|
|
"--source", str(project_root), "--product", product, "--out", str(out)],
|
|
cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True,
|
|
)
|
|
return result.stdout.strip() == "true"
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
|
|
def source_build_env(base_env: dict | None = None, *, explicit: bool = False, verify: bool = True) -> dict[str, str]:
|
|
from pm import ensure
|
|
from pm.environments import project_python, running_from_selected_environment
|
|
from pm.paths import repo_root
|
|
from hermes_constants import get_hermes_home
|
|
|
|
# The historical update runs on store Python with the selected environment
|
|
# activated in-process. Icon generation starts an isolated child, which needs
|
|
# the selected venv executable rather than the store interpreter.
|
|
root = repo_root()
|
|
python = str(project_python(root)) if running_from_selected_environment(root) else sys.executable
|
|
env = {**os.environ, **(base_env or {}), "CI": "1", "HERMES_PYTHON": python,
|
|
"PYTHON": python}
|
|
# Electron's installer unpacks its binary with a native module that imports
|
|
# VCRUNTIME140.dll, which a fresh Windows lacks. Our Python ships one, and
|
|
# scripts/build/electron-runtime.mjs copies it beside that module.
|
|
vc_runtime = Path(sys.base_prefix) / "vcruntime140.dll"
|
|
if vc_runtime.is_file():
|
|
env["HERMES_VC_RUNTIME"] = str(vc_runtime)
|
|
env.pop("ESBUILD_BINARY_PATH", None)
|
|
npmrc = get_hermes_home() / "npmrc"
|
|
if npmrc.is_file():
|
|
env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc))
|
|
return ensure("npm", base_env=env, explicit=explicit, verify=verify).env
|
|
|
|
|
|
def run_in_custody(project_root: Path, command: list, label: str, **kwargs):
|
|
"""``pm.progress.run_contained`` for a build command that writes the checkout (node, npm):
|
|
it and everything it starts stay in the update's custody (POSIX: the checkout lock fd, the
|
|
caller's process group, and every descendant killed when it exits; Windows: the owner's
|
|
kill-on-close job), so the checkout is never handed to a contender while one of them still
|
|
writes. Outside an update it is ``run_contained`` as is."""
|
|
from pm.progress import run_contained
|
|
from hermes_cli.update_custody import contained_command
|
|
|
|
with contained_command(command, root=project_root) as (argv, custody):
|
|
return run_contained(argv, label, **kwargs, **custody)
|
|
|
|
|
|
def run_source_script(project_root: Path, script: str, *args: str, env: dict, label: str) -> None:
|
|
command = [shutil.which("node", path=env["PATH"]), str(project_root / script), *args]
|
|
# npm's deprecation warnings are the loudest lines and never actionable
|
|
# here; they still land in the failure tail.
|
|
run_in_custody(project_root, command, label, hide=lambda line: line.lower().startswith("npm warn"),
|
|
indent=" ", cwd=project_root, env=env)
|
|
|
|
|
|
def _install_args(explicit: bool) -> tuple[str, ...]:
|
|
from pm import lazy_installs_allowed
|
|
|
|
return () if explicit or lazy_installs_allowed() else ("--no-install",)
|
|
|
|
|
|
def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict,
|
|
explicit: bool = False) -> None:
|
|
run_source_script(
|
|
project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse",
|
|
*_install_args(explicit),
|
|
*(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env,
|
|
label="Preparing Node dependencies",
|
|
)
|
|
|
|
|
|
def prepare_launch_dependencies(project_root: Path, *, env: dict) -> None:
|
|
"""A launch rebuild must not prune another installed source frontend."""
|
|
from hermes_cli.main_desktop import _desktop_dist_exists, _desktop_packaged_executable
|
|
|
|
desktop_dir = project_root / "apps/desktop"
|
|
desktop = _desktop_dist_exists(desktop_dir) or _desktop_packaged_executable(desktop_dir) is not None
|
|
workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ())
|
|
prepare_source_dependencies(project_root, workspaces, env=env)
|
|
|
|
|
|
def build_source_tui(project_root: Path, *, env: dict) -> None:
|
|
run_source_script(project_root, "scripts/build/tui.mjs", env=env, label="Building the TUI")
|
|
|
|
|
|
def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None:
|
|
# Bounded build (#63338): the Vite/Rolldown dashboard build saturates small
|
|
# hosts; cap the V8 heap and the native bundler's rayon thread pool.
|
|
from hermes_cli.web_build_limits import apply_web_build_limits
|
|
|
|
apply_web_build_limits(env)
|
|
# Default-brand icons are committed; installs never render them.
|
|
icons = icons or project_root
|
|
run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root),
|
|
"--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env,
|
|
label="Building the web UI")
|
|
|
|
|
|
def build_source_webapp(project_root: Path, *, env: dict, explicit: bool = False) -> None:
|
|
"""Browser renderer; its dependencies live in a private workspace, never the checkout's."""
|
|
run_source_script(project_root, "apps/desktop/scripts/build-webapp.mjs", *_install_args(explicit),
|
|
env=env, label="Building the Webapp renderer")
|
|
|
|
|
|
def source_frontends(project_root: Path) -> tuple[str, ...]:
|
|
"""The frontend workspaces this checkout carries. A source slice without them
|
|
(python-only installs, the installer's acceptance fixture) has no products
|
|
to build; it still publishes commands and runs the maintenance tail."""
|
|
return tuple(name for name in ("ui-tui", "web") if (project_root / name / "package.json").is_file())
|
|
|
|
|
|
class ProductBuildError(RuntimeError):
|
|
"""One or more products failed; every independent product was still attempted."""
|
|
|
|
def __init__(self, failures: list[tuple[str, BaseException]]):
|
|
self.failures = failures
|
|
super().__init__("; ".join(f"{name}: {_failure_text(exc)}" for name, exc in failures))
|
|
|
|
|
|
def _failure_text(exc: BaseException) -> str:
|
|
if isinstance(exc, subprocess.CalledProcessError):
|
|
return f"{' '.join(map(str, exc.cmd)) if isinstance(exc.cmd, (list, tuple)) else exc.cmd} exited {exc.returncode}"
|
|
return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
|
|
|
|
def _headless_linux() -> bool:
|
|
return sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
|
|
|
|
def build_update_products(project_root: Path, *, desktop: bool) -> None:
|
|
"""Prepare the selected union once, attempting every independent product.
|
|
|
|
A failed product never skips the next one (a broken web build must not leave the TUI or the
|
|
desktop app stale); the failures are raised together at the end as ``ProductBuildError``.
|
|
"""
|
|
# Both current updates and historical takeover reach this in a fresh target
|
|
# interpreter, never in the updater's pre-sync import graph.
|
|
from hermes_cli.main_install_repair import _install_configured_features_missing_deps
|
|
from hermes_cli.update_stage import publish_stage
|
|
|
|
failures: list[tuple[str, BaseException]] = []
|
|
|
|
def attempt(name: str, step) -> bool:
|
|
try:
|
|
step()
|
|
return True
|
|
except Exception as exc: # health: allow BLE001 -- collected and raised after the independent steps
|
|
print(f" ⚠ {name} failed: {_failure_text(exc)}")
|
|
failures.append((name, exc))
|
|
return False
|
|
|
|
attempt("feature dependencies", lambda: _install_configured_features_missing_deps(project_root))
|
|
frontends = source_frontends(project_root)
|
|
if frontends:
|
|
desktop_built = False
|
|
env: dict = {}
|
|
workspaces = frontends + (("apps/desktop",) if desktop else ())
|
|
publish_stage("Updating Node dependencies")
|
|
|
|
def node_dependencies(selected: tuple[str, ...]) -> None:
|
|
# Acquiring npm is part of this step: its failure must be reported like the install's.
|
|
# A recorded install is trusted as at startup (re-hashing node+npm costs ~2 s per
|
|
# tail); a missing one is still installed explicitly.
|
|
env.update(source_build_env(explicit=True, verify=False))
|
|
prepare_source_dependencies(project_root, selected, env=env, explicit=True)
|
|
|
|
# Every product compiles from these node_modules: without them there is nothing to build.
|
|
prepared = attempt("Node dependencies", lambda: node_dependencies(workspaces))
|
|
desktop_skipped = False
|
|
if not prepared and desktop and env:
|
|
# Only apps/desktop brings native addons (node-pty, electron) into the union: a host
|
|
# whose compiler cannot build them must still get the TUI and web UI it runs.
|
|
print(" → Retrying Node dependencies without the desktop app")
|
|
failed = failures.pop()
|
|
prepared = desktop_skipped = attempt("Node dependencies", lambda: node_dependencies(frontends))
|
|
if desktop_skipped:
|
|
failures.append(("desktop dependencies", failed[1]))
|
|
desktop_reason = failures[-1][0] if failures else ""
|
|
if desktop and (desktop_skipped or _headless_linux()):
|
|
print(" ⓘ If this host never runs the desktop app, `hermes uninstall --gui` removes it "
|
|
"and future updates stop building it.")
|
|
if prepared:
|
|
# An update that changed no TUI/web input reuses the receipted output, as the
|
|
# launch path already does; recompiling it produces the same bytes. Desktop
|
|
# additionally needs the packaged app to name HEAD (its baked stamp carries the
|
|
# commit), so it is reused only when HEAD did not move: "Already up to date",
|
|
# a retried tail, a takeover re-entry.
|
|
if "ui-tui" in frontends:
|
|
if source_product_current(project_root, "tui", project_root / "ui-tui/dist"):
|
|
print(" ✓ TUI is up to date")
|
|
else:
|
|
publish_stage("Building the TUI")
|
|
attempt("TUI build", lambda: build_source_tui(project_root, env=env))
|
|
if "web" in frontends:
|
|
if source_product_current(project_root, "web", project_root / "hermes_cli/web_dist"):
|
|
print(" ✓ Web UI is up to date")
|
|
else:
|
|
publish_stage("Building the web UI")
|
|
attempt("web UI build", lambda: build_source_web(project_root, env=env))
|
|
if desktop and not desktop_skipped:
|
|
desktop_built = attempt("desktop app build", lambda: _build_desktop_product(project_root, env, publish_stage))
|
|
desktop_reason = failures[-1][0] if not desktop_built else ""
|
|
if desktop and not desktop_built:
|
|
# One whole line the Desktop hand-off scripts match: the follow-up text is truncated
|
|
# and names whichever products failed first, so it cannot say if THIS app was rebuilt.
|
|
print(f" Desktop app build owed: {desktop_reason} failed")
|
|
if (project_root / "apps/desktop/scripts/build-webapp.mjs").is_file():
|
|
# Its dependencies live in a private workspace, so it never waits on the union above.
|
|
attempt("Webapp build", lambda: _rebuild_served_webapp(project_root, publish_stage))
|
|
# A configured memory provider that no longer ships in core is installed from the
|
|
# catalog for every profile home sharing this venv (config, data and tool names
|
|
# unchanged). The update must finish even if the migration blows up.
|
|
try:
|
|
from hermes_cli.memory_provider_migration import migrate_all_homes
|
|
|
|
migrate_all_homes()
|
|
except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
|
|
print(f" ⚠ Memory provider migration skipped: {exc}")
|
|
# Same for a gateway platform / toolset that left core (Home Assistant): every home that used
|
|
# it gets its catalog plugin (hermes_cli/left_core_migration.py).
|
|
try:
|
|
from hermes_cli.left_core_migration import migrate_all_homes as migrate_left_core
|
|
|
|
migrate_left_core()
|
|
except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it
|
|
print(f" ⚠ Plugin migration skipped: {exc}")
|
|
if failures:
|
|
raise ProductBuildError(failures)
|
|
|
|
|
|
def _rebuild_served_webapp(project_root: Path, publish_stage) -> None:
|
|
"""The Webapp bundle (``apps/desktop/dist-webapp``), when a home sharing this install serves it
|
|
(``webapp.enabled``): its gateway serves the bundle as built, so one older than the updated
|
|
backend would talk to it with an outdated contract. Built with the function ``hermes webapp``
|
|
uses, which reuses a current bundle. A failed build leaves the previous bundle on disk, marked
|
|
out of date: its gateways report the Webapp unavailable until ``hermes webapp`` rebuilds it."""
|
|
from gateway.webapp_listener import webapp_settings
|
|
from hermes_cli.webapp import mark_webapp_stale, prepare_webapp_renderer
|
|
from pm.plugins_state import dependency_homes
|
|
|
|
if not any(webapp_settings(home).enabled for home in dependency_homes()):
|
|
return
|
|
publish_stage("Building the Webapp")
|
|
try:
|
|
prepare_webapp_renderer(project_root, explicit=True)
|
|
except BaseException:
|
|
try:
|
|
if mark_webapp_stale(project_root):
|
|
print(" ⓘ The Webapp stays unavailable until `hermes webapp` rebuilds it.")
|
|
except OSError as exc:
|
|
print(f" ⚠ Could not mark the Webapp out of date: {exc}")
|
|
raise
|
|
|
|
|
|
def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None:
|
|
from hermes_cli.main_desktop import (
|
|
_packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)
|
|
|
|
desktop_dir = project_root / "apps/desktop"
|
|
if _packaged_desktop_current_for_head(desktop_dir, project_root):
|
|
print(" ✓ Desktop app is up to date")
|
|
else:
|
|
publish_stage("Building the desktop app")
|
|
# The desktop build mutates checkout-scoped node_modules and
|
|
# apps/desktop/release; serialize it against a concurrent manual
|
|
# `hermes desktop` (#93940). The update path waits rather than exits:
|
|
# the in-flight build it queues behind produces the same fresh tree
|
|
# this update needs.
|
|
from hermes_cli.desktop_build_lock import DesktopBuildLock
|
|
|
|
build_lock = DesktopBuildLock(project_root)
|
|
build_lock.acquire(wait=True)
|
|
try:
|
|
build_prepared_desktop(
|
|
desktop_dir, source_mode=False,
|
|
npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root,
|
|
)
|
|
finally:
|
|
build_lock.release()
|
|
# A current release/ can still sit beside a stale installed copy (an earlier
|
|
# update rebuilt but never installed); healing must not wait for the next build.
|
|
_refresh_installed_desktop_apps(desktop_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Build source-install frontends")
|
|
parser.add_argument("--source", type=Path, required=True)
|
|
parser.add_argument("--desktop", action="store_true")
|
|
args = parser.parse_args()
|
|
build_update_products(args.source.resolve(), desktop=args.desktop)
|