Files
hermes-agent/hermes_cli/webapp.py
T
emozilla 04318229b2 fix(webapp): report the Webapp unavailable when hermes update could not rebuild it
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.
2026-10-25 03:22:33 -07:00

118 lines
4.7 KiB
Python

"""Build and launch plumbing for the browser-hosted Desktop workspace.
The browser surface shares Hermes' hardened dashboard server. This module owns
only the separate renderer artifact and the one-way handoff into that server;
it never creates a second HTTP/WebSocket stack. The renderer is compiled by the
shared product builders (``apps/desktop/scripts/build-webapp.mjs``), whose
receipt inside ``dist-webapp`` decides freshness.
"""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
import subprocess
# Serialize renderer publication with Dashboard builds in this checkout.
_LOCK_NAME = ".web_ui_build.lock"
_LOCK_WAIT_SECONDS = 30 * 60
# Written into the bundle when `hermes update` could not rebuild it, so the gateways serving it report
# the Webapp unavailable instead of serving it against the updated backend. Publishing a build
# replaces the whole folder, and the receipt's output hash covers this file, so a marked bundle is
# never reused as current.
STALE_MARKER = ".hermes-stale"
class WebappBuildError(RuntimeError):
"""The browser-hosted Desktop renderer could not be prepared."""
def webapp_dist_dir(project_root: Path) -> Path:
return project_root / "apps" / "desktop" / "dist-webapp"
def mark_webapp_stale(project_root: Path) -> bool:
"""Mark the built bundle in *project_root* out of date, after a rebuild that failed. False when
no bundle is built: nothing serves one, and the build output folder stays the builder's."""
dist = webapp_dist_dir(project_root)
if not (dist / "index.html").is_file():
return False
(dist / STALE_MARKER).write_text("hermes update could not rebuild this bundle\n", encoding="utf-8")
return True
def webapp_bundle_stale(dist: Path) -> bool:
"""Whether the bundle in *dist* is marked out of date (:func:`mark_webapp_stale`)."""
return (Path(dist) / STALE_MARKER).is_file()
def _clear_stale_mark(dist: Path) -> None:
try:
(dist / STALE_MARKER).unlink(missing_ok=True)
except OSError as exc:
raise WebappBuildError(f"Could not mark the Webapp renderer at {dist} current: {exc}") from exc
@contextmanager
def _exclusive_build_lock(path: Path):
"""Cross-platform exclusive lock for one renderer generation."""
from pm.filesystem import lock_fd, unlock_fd
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+b")
except OSError as exc:
raise WebappBuildError(f"Could not open Webapp build lock {path}: {exc}") from exc
try:
try:
if not lock_fd(handle.fileno(), wait=False):
print("→ Another Hermes Webapp build is running; waiting for it...")
if not lock_fd(handle.fileno(), wait=True, timeout=_LOCK_WAIT_SECONDS):
raise WebappBuildError(f"Timed out waiting for another Webapp build ({path})")
except OSError as exc:
raise WebappBuildError(f"Could not lock Webapp build {path}: {exc}") from exc
yield
finally:
unlock_fd(handle.fileno())
handle.close()
def prepare_webapp_renderer(
project_root: Path,
*,
force: bool = False,
skip_build: bool = False,
explicit: bool = False,
) -> Path:
"""Return a verified browser renderer, serializing concurrent builds.
``explicit`` (a requested build) may install dependencies even when lazy
installs are disabled, like ``hermes desktop --build-only``.
"""
from hermes_cli.source_build import build_source_webapp, source_build_env, source_product_current
project_root = project_root.resolve()
dist = webapp_dist_dir(project_root)
if skip_build:
# Reuse takes no build lock, like `hermes dashboard --skip-build`: the
# lock lives in the install root, which a read-only install cannot write.
if not (dist / "index.html").is_file():
raise WebappBuildError(
f"--skip-build was passed but no Webapp renderer exists at {dist}"
)
print(f"→ Reusing Hermes Webapp renderer at {dist} (--skip-build)")
return dist
with _exclusive_build_lock(project_root / _LOCK_NAME):
if not force and source_product_current(project_root, "webapp", dist):
_clear_stale_mark(dist)
print(f"✓ Hermes Webapp renderer is up to date: {dist}")
return dist
try:
build_source_webapp(project_root, env=source_build_env(explicit=explicit), explicit=explicit)
except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
raise WebappBuildError(f"Browser-hosted Desktop build failed: {exc}") from exc
_clear_stale_mark(dist)
print(f"✓ Hermes Webapp renderer built: {dist}")
return dist