Docker image missing built web dashboard assets

NousResearch/hermes-agent · cluster 12177 · environments

Data as of

Nothing open on GitHub. Fix merged in #12180. 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 #12180. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #12180. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern the Hermes dashboard being unavailable or broken in Docker images because web UI assets are not pre-built. Proposed fixes involve building assets during the Docker image build and skipping unnecessary rebuilds when assets are already present.

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.

  • #12180 fix(docker): build web/ dashboard assets in image

    merged to main

    files: Dockerfile

    Show the diff
    • Dockerfile
      @@ -31,6 +31,12 @@ RUN npm install --prefer-offline --no-audit && \
           npm install --prefer-offline --no-audit && \
           npm cache clean --force
       
      +# Build the web/ dashboard so FastAPI at :9119 can serve the Vite assets
      +RUN cd /opt/hermes/web && \
      +    npm install --prefer-offline --no-audit && \
      +    npm run build && \
      +    npm cache clean --force
      +
       # Hand ownership to hermes user, then install Python deps in a virtualenv
       RUN chown -R hermes:hermes /opt/hermes
       USER hermes
  • #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

1 PR closed without merging

From the close comments: 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
Closed issues · 2
  • Closed#12177[Bug]: Docker image 'hermes dashboard' 404s because web/ assets are not built
  • Closed#12243Dashboard fails to start in official Docker image due to read-only overlayfs layer blocking npm install
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

Subsystem
environments
Merged PRs
#12180, #16292
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
27 Apr 2026

For your agent

The same facts, typed.