Files
unsupportedpastels 3f2b1722b1 feat(nix): ship the Webapp in the Nix package
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.
2026-10-27 09:42:22 -04:00

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