Web UI rebuild triggering logic fixes and controls

NousResearch/hermes-agent · cluster 14898

Data as of

Nothing open on GitHub. Fix merged in #16292. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #16292. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #16292. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address problems with the dashboard or CLI unnecessarily rebuilding the web UI on startup, including stale-detection path bugs, OOM on low-memory systems, and adding options to skip or defer builds.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #16292 fix(cli): skip web UI rebuild when dist is fresh (salvage #15863, closes #14898)

    merged to main

    files: hermes_cli/main.py, scripts/release.py, tests/hermes_cli/test_web_ui_build.py

    Show the diff
    • hermes_cli/main.py
      @@ -4984,6 +4984,43 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
           return default
       
       
      +def _web_ui_build_needed(web_dir: Path) -> bool:
      +    """Return True if the web UI dist is missing or stale.
      +
      +    Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
      +    The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
      +    outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``.  Uses the Vite
      +    manifest as the sentinel because it is written last and therefore has the
      +    newest mtime of any build output.
      +    """
      +    dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
      +    sentinel = dist_dir / ".vite" / "manifest.json"
      +    if not sentinel.exists():
      +        sentinel = dist_dir / "index.html"
      +    if not sentinel.exists():
      +        return True
      +    dist_mtime = sentinel.stat().st_mtime
      +    skip = frozenset({"node_modules", "dist"})
      +    for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
      +        dirnames[:] = [d for d in dirnames if d not in skip]
      +        for fn in filenames:
      +            if fn.endswith((".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".vue")):
      +                if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:
      +                    return True
      +    for meta in (
      +        "package.json",
      +        "package-lock.json",
      +        "yarn.lock",
      +        "pnpm-lock.yaml",
      +        "vite.config.ts",
      +        "vite.config.js",
      +    ):
      +        mp = web_dir / meta
      +        if mp.exists() and mp.stat().st_mtime > dist_mtime:
      +            return True
      +    return False
      +
      +
       def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
           """Build the web UI frontend if npm is available.
       
      @@ -4997,6 +5034,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
           if not (web_dir / "package.json").exists():
               return True
       
      +    if not _web_ui_build_needed(web_dir):
      +        return True
      +
           npm = shutil.which("npm")
           if not npm:
               if fatal:
    • scripts/release.py
      @@ -54,6 +54,7 @@
           "[redacted:email]": "benjaminsehl",
           "[redacted:email]": "Nerijusas",
           "[redacted:email]": "Ito-69",
      +    "[redacted:email]": "georgeglessner",
           "[redacted:email]": "maxims-oss",
           # contributors (from noreply pattern)
           "[redacted:email]": "davidvv",
    • tests/hermes_cli/test_web_ui_build.py
      @@ -0,0 +1,121 @@
      +"""Tests for _web_ui_build_needed — staleness check for the web UI dist.
      +
      +Critical invariant: the Vite build outputs to hermes_cli/web_dist/
      +(vite.config.ts: outDir: "../hermes_cli/web_dist"), NOT web/dist/.
      +The sentinel must be checked in the correct output directory or the
      +freshness check is a no-op and the OOM rebuild always runs.
      +"""
      +
      +import os
      +import time
      +from pathlib import Path
      +from unittest.mock import patch
      +
      +import pytest
      +
      +from hermes_cli.main import _web_ui_build_needed, _build_web_ui
      +
      +
      +def _touch(path: Path, offset: float = 0.0) -> None:
      +    path.parent.mkdir(parents=True, exist_ok=True)
      +    path.touch()
      +    if offset:
      +        t = time.time() + offset
      +        os.utime(path, (t, t))
      +
      +
      +def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]:
      +    """Return (web_dir, dist_dir) matching real repo layout."""
      +    web_dir = tmp_path / "web"
      +    web_dir.mkdir()
      +    (web_dir / "package.json").touch()
      +    dist_dir = tmp_path / "hermes_cli" / "web_dist"
      +    return web_dir, dist_dir
      +
      +
      +class TestWebUIBuildNeeded:
      +
      +    def test_returns_true_when_dist_missing(self, tmp_path):
      +        web_dir, _ = _make_web_dir(tmp_path)
      +        assert _web_ui_build_needed(web_dir) is True
      +
      +    def test_returns_false_when_vite_manifest_fresh(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(web_dir / "src" / "App.tsx", offset=-10)
      +        _touch(dist_dir / ".vite" / "manifest.json")
      +        assert _web_ui_build_needed(web_dir) is False
      +
      +    def test_returns_true_when_source_newer_than_manifest(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
      +        _touch(web_dir / "src" / "App.tsx")
      +        assert _web_ui_build_needed(web_dir) is True
      +
      +    def test_falls_back_to_index_html_when_manifest_missing(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(web_dir / "src" / "main.ts", offset=-10)
      +        _touch(dist_dir / "index.html")
      +        assert _web_ui_build_needed(web_dir) is False
      +
      +    def test_web_dist_dir_not_web_dist_subdir(self, tmp_path):
      +        """Regression: sentinel must be in hermes_cli/web_dist/, NOT web/dist/."""
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(web_dir / "src" / "App.tsx", offset=-10)
      +        # Place manifest in wrong location (web/dist/) — should NOT count as fresh
      +        wrong_dist = web_dir / "dist" / ".vite" / "manifest.json"
      +        _touch(wrong_dist)
      +        # Correct location is empty → still needs build
      +        assert _web_ui_build_needed(web_dir) is True
      +
      +    def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
      +        _touch(web_dir / "package-lock.json")
      +        assert _web_ui_build_needed(web_dir) is True
      +
      +    def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
      +        _touch(web_dir / "vite.config.ts")
      +        assert _web_ui_build_needed(web_dir) is True
      +
      +    def test_ignores_node_modules(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        # package.json older than manifest; only node_modules file is newer
      +        _touch(web_dir / "package.json", offset=-20)
      +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
      +        _touch(web_dir / "node_modules" / "react" / "index.js")
      +        assert _web_ui_build_needed(web_dir) is False
      +
      +    def test_ignores_dist_subdir_under_web(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        # package.json older than manifest; only web/dist file is newer
      +        _touch(web_dir / "package.json", offset=-20)
      +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
      +        _touch(web_dir / "dist" / "assets" / "index.js")
      +        assert _web_ui_build_needed(web_dir) is False
      +
      +
      +class TestBuildWebUISkipsWhenFresh:
      +
      +    def test_skips_npm_when_dist_is_fresh(self, tmp_path):
      +        web_dir, dist_dir = _make_web_dir(tmp_path)
      +        _touch(dist_dir / ".vite" / "manifest.json")
      +
      +        with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
      +             patch("hermes_cli.main.subprocess.run") as mock_run:
      +            result = _build_web_ui(web_dir)
      +
      +        assert result is True
      +        mock_run.assert_not_called()
      +
      +    def test_runs_npm_when_dist_missing(self, tmp_path):
      +        web_dir, _ = _make_web_dir(tmp_path)
      +
      +        mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout=b"", stderr=b"")
      +        with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
      +             patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
      +            result = _build_web_ui(web_dir)
      +
      +        assert result is True
      +        assert mock_run.call_count == 2  # npm install + npm run build

2 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1

  • #14914 fix(cli): skip web UI rebuild when dist/ is present and sources are unchanged · superseded by another PR → #16292, #15863
  • #15863 fix(cli): check hermes_cli/web_dist/ not web/dist/ for build staleness (fixes #14914 regression) · merged via another change → #16292
Closed issues · 2
  • Closed#14898Dashboard rebuilds web UI on every startup - causes OOM on low-memory VPS (≤1GB)
  • Closed#32533fix(dashboard): systemd service crash-loop when web_dist exists but npm unavailable
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#16292
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
27 Apr 2026

For your agent

The same facts, typed.