mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The Nix package had no Webapp bundle, and `hermes webapp` could not build one in the read-only store. nix/webapp.nix builds it in the npm workspace the hook prepares (no native rebuild, no Electron), exposed as `.#webapp`. The agent assembler takes it as a `webapp` frontend: the wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the dashboard. hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no freshness check, no build lock, never rebuilt or marked stale, and `--force-build` says it cannot rebuild it.
142 lines
6.1 KiB
Python
142 lines
6.1 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
|
|
import os
|
|
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"
|
|
# A packaged bundle (the Nix package), like HERMES_WEB_DIST for the dashboard: the packager
|
|
# builds it, so Hermes serves it as it is and never builds or marks it.
|
|
DIST_ENV = "HERMES_WEBAPP_DIST"
|
|
|
|
|
|
class WebappBuildError(RuntimeError):
|
|
"""The browser-hosted Desktop renderer could not be prepared."""
|
|
|
|
|
|
def packaged_webapp_dist() -> Path | None:
|
|
"""The bundle :data:`DIST_ENV` names, or None when Hermes builds its own."""
|
|
value = os.environ.get(DIST_ENV)
|
|
return Path(value).expanduser() if value else None
|
|
|
|
|
|
def webapp_dist_dir(project_root: Path) -> Path:
|
|
return packaged_webapp_dist() or 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. Also
|
|
False for a packaged bundle, which is the packager's."""
|
|
dist = webapp_dist_dir(project_root)
|
|
if packaged_webapp_dist() is not None or 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 packaged_webapp_dist() is not None:
|
|
# The packager built it into a read-only store; there is nothing here to rebuild.
|
|
if not (dist / "index.html").is_file():
|
|
raise WebappBuildError(f"{DIST_ENV} is set but no Webapp renderer exists at {dist}")
|
|
if force:
|
|
raise WebappBuildError(f"The Webapp renderer at {dist} comes from {DIST_ENV} and cannot be rebuilt here")
|
|
print(f"→ Using Hermes Webapp renderer from {DIST_ENV}: {dist}")
|
|
return dist
|
|
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
|
|
# A current, unmarked bundle needs no lock either, so a read-only install
|
|
# (the Docker image) reuses the one baked at build time.
|
|
if not force and not webapp_bundle_stale(dist) and source_product_current(project_root, "webapp", dist):
|
|
print(f"✓ Hermes Webapp renderer is up to date: {dist}")
|
|
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
|