mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +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.
118 lines
4.7 KiB
Python
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
|