From 18a3fa57bdb49222d26b33c8b88583567c0e880c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:40:49 -0700 Subject: [PATCH] fix(dashboard): attempt one recovery build when --skip-build finds no dist --skip-build with a missing web_dist/index.html previously hard-failed with sys.exit(1) (issue #59288). The desktop launcher passes --build-mode skip on every boot, so a wiped or never-populated dist bricked the dashboard until the user manually rebuilt. Now the default-dist path logs a clear warning and attempts exactly ONE recovery build through the existing _build_web_ui path. If the recovery build also fails to produce index.html, the original fatal behavior is preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast: the build writes to the default dist location and cannot populate a caller-managed directory. Closes #59288 --- hermes_cli/main.py | 23 +++- .../test_dashboard_web_dist_validation.py | 112 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d413df90824..dac67ddabcb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12778,10 +12778,25 @@ def cmd_dashboard(args): else PROJECT_ROOT / "hermes_cli" / "web_dist" ) if not (_dist_root / "index.html").exists(): - print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}") - print(" Pre-build first: npm install --workspace web && npm run build -w web") - print(" Or drop --skip-build to build automatically.") - sys.exit(1) + # The caller promised a pre-built dist but there isn't one. + # Instead of hard-failing (issue #59288 — desktop launches with + # --build-mode skip after a wipe of web_dist), warn and attempt + # ONE recovery build through the normal build path. Only the + # default dist location is recoverable: a custom HERMES_WEB_DIST + # points at a caller-managed directory the build cannot populate. + _recoverable = "HERMES_WEB_DIST" not in os.environ + if _recoverable: + print(f"⚠ --skip-build was passed but no web dist found at: {_dist_root}") + print(" Attempting one recovery build of the web UI...") + _build_web_ui(PROJECT_ROOT / "web", fatal=True) + if not (_dist_root / "index.html").exists(): + print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}") + if _recoverable: + print(" The recovery build did not produce a usable dist.") + print(" Pre-build first: npm install --workspace web && npm run build -w web") + print(" Or drop --skip-build to build automatically.") + sys.exit(1) + print(" ✓ Recovery build produced a web dist") print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") else: # HERMES_WEB_DIST is set without --skip-build: the build is skipped diff --git a/tests/hermes_cli/test_dashboard_web_dist_validation.py b/tests/hermes_cli/test_dashboard_web_dist_validation.py index d1d7a780840..0f5d49d43f4 100644 --- a/tests/hermes_cli/test_dashboard_web_dist_validation.py +++ b/tests/hermes_cli/test_dashboard_web_dist_validation.py @@ -134,3 +134,115 @@ def test_env_dist_tilde_expanded_for_web_server(main_mod, monkeypatch, tmp_path) import os assert os.environ["HERMES_WEB_DIST"] == str(dist) + + +# --------------------------------------------------------------------------- +# --skip-build recovery (issue #59288): a missing dist under --skip-build +# should warn and attempt ONE recovery build via _build_web_ui before the +# fatal exit, instead of hard-failing immediately. +# --------------------------------------------------------------------------- + + +def test_skip_build_missing_dist_attempts_one_recovery_build( + main_mod, monkeypatch, tmp_path, capsys +): + """--skip-build + missing index.html triggers exactly one recovery build; + when the build produces a dist, the server starts.""" + _wire_common(main_mod, monkeypatch) + monkeypatch.delenv("HERMES_WEB_DIST", raising=False) + project_root = tmp_path / "proj" + dist = project_root / "hermes_cli" / "web_dist" + dist.mkdir(parents=True) + monkeypatch.setattr(main_mod, "PROJECT_ROOT", project_root) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + + builds = [] + + def fake_build(web_dir, *, fatal=False): + builds.append((web_dir, fatal)) + (dist / "index.html").write_text("", encoding="utf-8") + return True + + monkeypatch.setattr(main_mod, "_build_web_ui", fake_build) + + main_mod.cmd_dashboard(_args(skip_build=True)) + + assert len(builds) == 1 # exactly ONE recovery build + assert builds[0][0] == project_root / "web" + assert len(started) == 1 + out = capsys.readouterr().out + assert "recovery build" in out.lower() + + +def test_skip_build_recovery_build_failure_preserves_fatal_exit( + main_mod, monkeypatch, tmp_path, capsys +): + """When the recovery build also fails to produce a dist, the original + fatal path is preserved: exit 1, clear message, server never starts.""" + _wire_common(main_mod, monkeypatch) + monkeypatch.delenv("HERMES_WEB_DIST", raising=False) + project_root = tmp_path / "proj" + (project_root / "hermes_cli" / "web_dist").mkdir(parents=True) + monkeypatch.setattr(main_mod, "PROJECT_ROOT", project_root) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + + builds = [] + monkeypatch.setattr( + main_mod, + "_build_web_ui", + lambda web_dir, *, fatal=False: builds.append(web_dir) or False, + ) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args(skip_build=True)) + + assert exc.value.code == 1 + assert len(builds) == 1 # attempted once, never retried + assert started == [] + out = capsys.readouterr().out + assert "--skip-build was passed but no web dist found" in out + assert "recovery build did not produce a usable dist" in out + + +def test_skip_build_custom_env_dist_missing_does_not_attempt_recovery( + main_mod, monkeypatch, tmp_path, capsys +): + """A custom HERMES_WEB_DIST is caller-managed: the recovery build writes + to the default dist location and cannot populate it, so the env-var + + --skip-build combination keeps the immediate fatal exit with no build.""" + _wire_common(main_mod, monkeypatch) + empty_dist = tmp_path / "custom_dist" + empty_dist.mkdir() + monkeypatch.setenv("HERMES_WEB_DIST", str(empty_dist)) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + builds = [] + monkeypatch.setattr( + main_mod, "_build_web_ui", lambda *a, **k: builds.append(a) or True + ) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args(skip_build=True)) + + assert exc.value.code == 1 + assert builds == [] + assert started == [] + out = capsys.readouterr().out + assert "--skip-build was passed but no web dist found" in out