Files
hermes-agent/hermes_cli/main_web_build.py
T

728 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Web UI (dashboard frontend) build: content-hash stamps, npm install/build with idle timeout, bytecode sweep.
Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so
``hermes_cli.main.<name>`` keeps resolving (and monkeypatching) as before.
Names that stay in main are imported lazily inside the functions that use them
(call-time resolution keeps ``hermes_cli.main.<name>`` patches effective and
avoids an import cycle).
"""
import logging
import hashlib
import json
import os
import shutil
import subprocess
import sys
import threading
import time as _time
from pathlib import Path
from hermes_cli.main_tui_launch import (
_npm_lifecycle_env,
_termux_workspace_install_context,
_workspace_root,
)
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.main")
# Stamp file recording the checkout fingerprint the bytecode cache was last
# validated against. Lives next to the checkout (NOT in HERMES_HOME) because
# __pycache__ is per-checkout state shared by every profile.
_BYTECODE_FINGERPRINT_FILE = ".bytecode-fingerprint"
def _record_bytecode_fingerprint() -> None:
"""Persist the current checkout fingerprint after a bytecode sweep.
Never raises. A failed write just means the next launch re-sweeps —
safe, merely redundant.
"""
from hermes_cli.main import PROJECT_ROOT, _BYTECODE_FINGERPRINT_FILE, _read_git_revision_fingerprint
try:
fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT)
if not fingerprint:
return
stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE
tmp_path = stamp_path.with_name(stamp_path.name + ".tmp")
tmp_path.write_text(fingerprint, encoding="utf-8")
tmp_path.replace(stamp_path)
except OSError as exc:
logger.debug("Could not record bytecode fingerprint: %s", exc)
def _sweep_stale_bytecode_if_checkout_changed() -> None:
"""Clear ``__pycache__`` at launch when the checkout changed underneath us.
The stale-bytecode bug class (issues #6207, #60242; Dhruv's WhatsApp
``cannot import name 'parse_model_flags_detailed'`` report) has one
shared shape: the checkout's ``.py`` files change (git pull inside
``hermes update``, a manual ``git pull``, a ZIP update, a file-sync
restore) while ``__pycache__`` retains bytecode from the previous
revision, and a later process trusts the stale ``.pyc`` instead of the
fresh source.
Update-time clears alone can never close this class: ``hermes update``
always executes the PRE-pull updater code, so any hardening added to it
only takes effect one update late, and manual ``git pull`` never runs
the updater at all. This launch-time guard closes the loop: every
``hermes`` entry point compares the checkout fingerprint (cheap file
reads, no git subprocess) against the last-validated stamp and sweeps
the bytecode cache once when they diverge.
Never raises — a failure here must not block launch.
"""
from hermes_cli.main import PROJECT_ROOT, _BYTECODE_FINGERPRINT_FILE, _clear_bytecode_cache, _read_git_revision_fingerprint, _record_bytecode_fingerprint
try:
fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT)
if not fingerprint:
return # non-git install — the ZIP update path clears explicitly
stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE
try:
recorded = stamp_path.read_text(encoding="utf-8").strip()
except OSError:
recorded = ""
if recorded == fingerprint:
return
removed = _clear_bytecode_cache(PROJECT_ROOT)
if removed:
logger.info(
"Checkout changed since last launch (%s -> %s): cleared %d stale __pycache__ director%s",
recorded or "unknown",
fingerprint,
removed,
"y" if removed == 1 else "ies",
)
_record_bytecode_fingerprint()
except Exception as exc:
logger.debug("Stale-bytecode launch sweep failed: %s", exc)
def _web_ui_build_needed(web_dir: Path) -> bool:
"""Return True if the web UI dist is missing or its source content changed.
Uses a SHA-256 content hash of the web source tree (the same approach
``_desktop_build_needed()`` already uses for the Electron build), NOT
mtime comparison. ``git checkout`` / ``git pull`` / ``hermes update``
rewrite source mtimes without changing content, which made the old
mtime check unreliable in both directions: it could skip a rebuild when
source had genuinely changed (serving a stale dashboard) and force a
rebuild when nothing had. A content hash is stable across mtime churn.
The dashboard source lives under ``web/`` but Vite outputs to
``hermes_cli/web_dist/`` (per vite.config.ts outDir), NOT ``web/dist/``,
so the dist directory is never part of the hashed source tree.
"""
project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
dist_dir = project_root / "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
stamp_file = _web_ui_stamp_path()
if not stamp_file.is_file():
return True
try:
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return True
if not isinstance(stamp_data, dict):
return True
saved_hash = stamp_data.get("contentHash")
if not saved_hash:
return True
return _compute_web_ui_content_hash(project_root, web_dir) != saved_hash
def _compute_web_ui_content_hash(project_root: Path, web_dir: Path) -> str:
"""Return a SHA-256 hex digest of the web UI source tree.
Covers ``web_dir`` (the dashboard frontend source) plus the root
``package.json`` / ``package-lock.json`` (workspace config that
determines dependency resolution). Mirrors
``_compute_desktop_content_hash()``: ignored paths (``node_modules/``,
``dist/``, ``*.pyc``, ...) are skipped via the repo-root ``.gitignore``
so build output never feeds back into its own staleness check.
"""
h = hashlib.sha256()
def _hash_file(path: Path) -> None:
rel = str(path.relative_to(project_root))
h.update(rel.encode())
h.update(b"\0")
try:
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
except OSError:
pass
h.update(b"\0")
from pathspec import PathSpec
gitignore = project_root / ".gitignore"
lines: list[str] = []
if gitignore.is_file():
lines = gitignore.read_text(encoding="utf-8").splitlines()
spec = PathSpec.from_lines("gitignore", lines)
# Root workspace config (single package-lock.json covers all workspaces).
for name in ("package.json", "package-lock.json"):
p = project_root / name
if p.is_file():
rel = str(p.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(p)
# Walk the web source tree, pruning ignored directories in-place so we
# never descend into node_modules/ or a stray dist/. Sort filenames for
# a deterministic, order-independent digest.
for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
dirnames[:] = [
d for d in dirnames
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
]
for fn in sorted(filenames):
fp = Path(dirpath) / fn
rel = str(fp.relative_to(project_root))
if not spec.match_file(rel):
_hash_file(fp)
return h.hexdigest()
def _web_ui_stamp_path() -> Path:
"""Return the path to the web UI build stamp file under $HERMES_HOME."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "web-ui-build-stamp.json"
def _write_web_ui_build_stamp(project_root: Path, web_dir: Path) -> None:
"""Write the web UI build stamp after a successful build."""
stamp_file = _web_ui_stamp_path()
try:
stamp_file.parent.mkdir(parents=True, exist_ok=True)
from datetime import datetime, timezone
stamp_data = {
"contentHash": _compute_web_ui_content_hash(project_root, web_dir),
"builtAt": datetime.now(timezone.utc).isoformat(),
}
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
except Exception as exc:
# Never let stamp-writing block or fail a build.
logger.debug("Failed to write web UI build stamp: %s", exc)
def _run_with_idle_timeout(
cmd: list[str],
cwd: Path,
*,
idle_timeout_seconds: int = 180,
indent: str = " ",
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, with an idle-output timeout.
Issue #33788: ``npm run build`` (Vite) was invoked with
``capture_output=True`` and no timeout. On low-memory hosts (notably
WSL2 with the default 4 GB cap) the build can stall or sit silent for
minutes; users see a frozen terminal, assume the update is hung, and
reboot — leaving the editable install in a half-state with the
``hermes`` launcher present but ``hermes_cli`` not importable.
This helper fixes both halves: stdout is streamed (so the user sees
progress), and if no bytes have appeared on stdout/stderr for
``idle_timeout_seconds``, the process is terminated and the call
returns with a non-zero ``returncode``. The caller's existing
stale-dist fallback (#23817) takes over from there.
Returns a ``CompletedProcess`` with merged stdout (text), empty
stderr, and an integer returncode. Never raises on idle timeout —
propagation of failure is via the returncode.
"""
merged_chunks: list[str] = []
last_output_ts = _time.monotonic()
lock = threading.Lock()
try:
proc = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
env=env,
)
except OSError as exc:
# E.g. npm not on PATH between the which() check and now.
return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc))
def _reader() -> None:
nonlocal last_output_ts
assert proc.stdout is not None
for line in proc.stdout:
try:
print(f"{indent}{line.rstrip()}", flush=True)
except UnicodeEncodeError:
# Windows cp1252 fallback — same pattern as _say().
enc = getattr(sys.stdout, "encoding", None) or "ascii"
safe = line.rstrip().encode(enc, errors="replace").decode(enc, errors="replace")
print(f"{indent}{safe}", flush=True)
with lock:
merged_chunks.append(line)
last_output_ts = _time.monotonic()
reader_thread = threading.Thread(target=_reader, daemon=True)
reader_thread.start()
idle_killed = False
while True:
try:
rc = proc.wait(timeout=5)
break
except subprocess.TimeoutExpired:
with lock:
idle = _time.monotonic() - last_output_ts
if idle > idle_timeout_seconds:
idle_killed = True
proc.terminate()
try:
rc = proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
rc = proc.wait()
break
# Drain reader so we don't leak the stdout file descriptor.
reader_thread.join(timeout=2)
combined = "".join(merged_chunks)
if idle_killed:
msg = (
f"\n ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n"
" Common causes: out-of-memory on a low-RAM host (WSL/container),\n"
" a stuck Node process, or an antivirus scan stalling I/O.\n"
)
combined += msg
# Force a non-zero rc even if terminate() raced with a clean exit.
if rc == 0:
rc = 124 # GNU `timeout` convention
return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="")
def _nixos_build_env() -> dict[str, str] | None:
"""Return extra env vars for native module builds on NixOS.
On NixOS, python3 is typically not on the system PATH (it lives in
the Nix store and only enters PATH inside a nix-shell or when
explicitly installed as a system package). node-gyp uses Python to
compile native addons like ``node-pty`` and its ``find-python.js``
does a bare ``PATH`` lookup — which fails on NixOS.
Two-tier resolution:
1. Fast path — the hermes venv's python3 (present in managed installs)
2. Fallback — resolves the absolute python3 path via ``nix-shell``
Returns an env dict suitable for ``subprocess.run(env=...)`` or
``None`` when we are not on NixOS or python3 is already on PATH.
"""
from hermes_cli.main import PROJECT_ROOT
import re
try:
os_release = Path("/etc/os-release").read_text(encoding="utf-8")
except OSError:
return None
if not re.search(r"^ID=nixos$", os_release, re.M):
return None
# python3 already on PATH — nothing to do
if shutil.which("python3"):
return None
# Tier 1: fast path — hermes venv python3, no nix-shell overhead
for venv_name in ("venv", ".venv"):
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
# Tier 2: nix-shell fallback — resolves the absolute python3 path once.
# Slower (~2–5 s for the nix-shell eval) but always works, even without
# a hermes venv (pip / non-managed / bare-git installs). The resolved
# path is a self-contained Nix store binary (all deps via RPATH) so it
# stays valid even after the nix-shell exits.
try:
result = subprocess.run(
["nix-shell", "-p", "python3", "--run", "which python3"],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=15,
)
if result.returncode == 0:
python3_path = result.stdout.strip()
if python3_path and Path(python3_path).exists():
return {**os.environ, "PYTHON": python3_path}
except Exception:
pass # nix-shell not available — caller will get None
return None
def _run_npm_install_deterministic(
npm: str,
cwd: Path,
*,
extra_args: tuple[str, ...] = (),
capture_output: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present;
falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of
sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently
rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves
the working tree dirty and causes the next ``hermes update`` to stash the
lockfile — repeatedly.
``--include=dev`` is forced on every invocation: the callers are frontend
builds (web UI / TUI / desktop workspaces), and those builds need the dev
toolchain (``tsc``, ``vite``, ``electron-builder`` — all
``devDependencies``). If the caller's environment has
``NODE_ENV=production`` (or npm config ``omit=dev``) — which leaks in from
a shell profile, a container image, or the bundled TUI launcher that sets
``NODE_ENV=production`` on its subprocess env — npm silently omits
devDependencies (exit 0, no error), so the build toolchain never installs
and the subsequent build dies with ``tsc: command not found`` (exit 127).
The flag overrides both the env var and npm config, unlike scrubbing
``NODE_ENV`` from the environment which only fixes the env-leak case.
``--no-save`` on the ``npm install`` fallback keeps it true to this
function's contract: never mutate ``package-lock.json``. Without it, an
out-of-sync lockfile gets rewritten by the fallback, which drifts the
committed lockfile and makes every future ``npm ci`` fail — a
self-reinforcing cycle where web devDeps never install and a stale dist
is served on every update (PR #65595).
"""
# unicode-animations' postinstall animates to /dev/tty (bypasses
# --silent/capture_output). It no-ops when CI is set — same as the TUI
# install path and nix/lib.nix npm ci hooks.
run_env = _npm_lifecycle_env(env)
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
return _run_npm_watching_for_engine_failure(
cmd,
cwd=cwd,
env=run_env,
capture_output=capture_output,
)
def _attempt(npm_exe: str) -> subprocess.CompletedProcess:
lockfile = cwd / "package-lock.json"
if lockfile.exists():
ci_result = _run([npm_exe, "ci", "--include=dev", *extra_args])
if ci_result.returncode == 0:
return ci_result
# Fall through to `npm install` — lockfile may be out of sync on a
# WIP fork/branch, or `npm ci` may not be available on very old npm.
return _run([npm_exe, "install", "--no-save", "--include=dev", *extra_args])
result = _attempt(npm)
if result.returncode == 0:
return result
# An npm outside the root package.json's `engines.npm` range fails every
# command here identically (the `npm install` fallback included), so the
# failure is worth exactly one repair attempt. `maybe_repair_npm_engine`
# returns the npm to retry with — the same one after an in-place upgrade
# of a Hermes-managed install, or a freshly provisioned managed npm when
# the failing npm belongs to the user's own toolchain.
from hermes_cli.npm_engine import maybe_repair_npm_engine
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
repaired_npm = maybe_repair_npm_engine(npm, combined)
if not repaired_npm:
return result
# The repaired npm may be a freshly provisioned managed one whose shebang
# and lifecycle scripts resolve `node` from PATH — put the managed tree
# first so they find the managed Node, not the mismatched system one.
from hermes_constants import with_hermes_node_path
run_env["PATH"] = with_hermes_node_path(run_env)["PATH"]
return _attempt(repaired_npm)
def _run_npm_watching_for_engine_failure(
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
capture_output: bool,
) -> subprocess.CompletedProcess:
"""Run *cmd*, always retaining stderr so ``EBADENGINE`` stays detectable.
``capture_output=False`` callers stream npm's progress live and would
otherwise hand back a ``CompletedProcess`` with ``stderr=None``, leaving the
engine-failure recovery nothing to read. Tee stderr instead: each line is
forwarded to this process's stderr as it arrives (so live output is
unchanged) and accumulated for the caller.
"""
if capture_output:
return subprocess.run(
cmd,
cwd=cwd,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
captured: list[str] = []
with subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
) as proc:
if proc.stderr is not None:
for line in proc.stderr:
captured.append(line)
sys.stderr.write(line)
sys.stderr.flush()
returncode = proc.wait()
return subprocess.CompletedProcess(cmd, returncode, None, "".join(captured))
def _missing_web_build_tool(output: str) -> str | None:
"""Return the build tool a failed ``npm run build`` could not resolve.
Each shell words this differently: ``sh: 1: tsc: not found`` (dash),
``vite: command not found`` (bash/zsh), and ``'tsc' is not recognized as
an internal or external command`` (cmd.exe).
"""
lowered = output.lower()
for tool in ("tsc", "vite"):
if any(
phrase in lowered
for phrase in (
f"{tool}: not found",
f"{tool}: command not found",
f"'{tool}' is not recognized",
)
):
return tool
return None
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available, serializing across processes.
Concurrent dashboard boots (e.g. the desktop app's retry loop after a
readiness timeout) used to each spawn their own ``npm install`` +
``vite build`` over the same tree; the parallel builds starved each
other, none finished, the dist sentinel never advanced, and every new
boot re-triggered the build. One process builds under an exclusive
flock; the rest serve the existing dist (stale is acceptable) or, when
no dist exists yet, block until the builder finishes.
Staleness is checked once, inside :func:`_do_build_web_ui`, after the
lock is held — so a process that queued behind the builder skips the
rebuild, and the (os.walk-based) check runs at most once per boot.
"""
if not (web_dir / "package.json").exists():
return True
try:
import fcntl
except ImportError:
# Windows: no flock — fall through to the unserialized build.
return _do_build_web_ui(web_dir, fatal=fatal)
project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
dist_index = project_root / "hermes_cli" / "web_dist" / "index.html"
try:
lock_file = open(project_root / ".web_ui_build.lock", "a", encoding="utf-8")
except OSError:
return _do_build_web_ui(web_dir, fatal=fatal)
try:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
if dist_index.exists():
# Another process is already building — serve the current
# dist instead of piling a second build onto the same tree.
return True
# No dist at all (first-ever build): wait for the builder.
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
return _do_build_web_ui(web_dir, fatal=fatal)
finally:
lock_file.close()
def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available.
Args:
web_dir: Path to the dashboard frontend source directory.
fatal: If True, print error guidance and return False on failure
instead of a soft warning (used by ``hermes web``).
Returns True if the build succeeded or was skipped (no package.json).
"""
from hermes_cli.main import _is_termux_startup_environment, _resolve_node_runtime_npm, _run_npm_install_deterministic, _run_with_idle_timeout, _web_ui_build_needed, _write_web_ui_build_stamp
if not (web_dir / "package.json").exists():
return True
if not _web_ui_build_needed(web_dir):
return True
# Console-encoding-safe print: Windows consoles default to cp1252
# (or similar) and will raise UnicodeEncodeError on arrow / check
# glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print
# in this function through _say() with errors="replace" keeps the
# build path usable on a stock `py -m hermes_cli.main web` invocation.
def _say(text: str) -> None:
try:
print(text)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
from hermes_constants import with_hermes_node_path
npm = _resolve_node_runtime_npm()
if not npm:
if fatal:
_say("Web UI frontend not built and npm is not available.")
_say("Install Node.js, then run: cd web && npm install && npm run build")
return not fatal
build_env = _npm_lifecycle_env(with_hermes_node_path())
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
"""Print captured npm output so users can see *why* a step failed.
Windows users hitting `rm -rf` / `cp -r` errors (or any other
sync-assets / Vite failure) would otherwise see only ``Web UI
build failed`` with no hint of the underlying cause, because
the npm calls run with ``capture_output=True``.
"""
for blob in (result.stdout, result.stderr):
if not blob:
continue
text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip()
if text:
_say(text)
npm_cwd = _workspace_root(web_dir)
# Scope the install to the web workspace only so that the full workspace
# graph (including apps/desktop with its Electron + node-pty deps) is never
# resolved here. Without --workspace the root package.json's apps/* glob
# would pull in desktop on every web build. See #38772.
# When web/ has its own package-lock.json, _workspace_root() returns
# web_dir itself and --workspace would fail. See #42973.
#
# When running from the workspace root, this must name the SAME closure
# as `hermes update`'s _update_node_dependencies() (ui-tui + web +
# --include-workspace-root): the helper prefers `npm ci`, which deletes
# node_modules before reifying the requested tree, so a narrower closure
# here silently prunes everything the update step just installed (root
# devDependencies and the ui-tui workspace) while still exiting 0 —
# and since the manifests digest was already recorded, later no-op
# updates skip the repair. See #43564/#64354.
npm_workspace_args: tuple[str, ...]
if npm_cwd == web_dir:
npm_workspace_args = ()
else:
npm_workspace_args = ("--workspace", "web", "--include-workspace-root")
# Prebuilt/partial checkouts can lack the ui-tui workspace; naming a
# missing workspace makes npm fail hard, so only include it when
# present (same guard as _update_node_dependencies()).
if (npm_cwd / "ui-tui" / "package.json").exists():
npm_workspace_args = ("--workspace", "ui-tui", *npm_workspace_args)
if _is_termux_startup_environment():
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
def _install_web_deps(*, silent: bool) -> "subprocess.CompletedProcess":
return _run_npm_install_deterministic(
npm,
npm_cwd,
extra_args=(*npm_workspace_args, "--silent", "--prefer-offline") if silent else (*npm_workspace_args, "--prefer-offline"),
env=build_env,
)
r1 = _install_web_deps(silent=True)
if r1.returncode != 0:
_say(
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
+ ("" if fatal else " (hermes web will not be available)")
)
_relay(r1)
if fatal:
_say(" Run manually: npm install --workspace web && npm run build -w web")
return False
# First attempt — stream output via idle-timeout helper (issue #33788).
# capture_output=True on a long Vite build looks identical to a hang;
# users react by rebooting, which leaves the editable install in a
# half-state. Streaming + idle-kill makes failures observable AND
# recoverable (the stale-dist fallback below handles the kill path).
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# The install above can exit 0 while leaving the tree without a build
# toolchain — a lockfile-hash skip over a half-installed tree, or an
# interrupted link step. The generic retry below just reruns the same
# command, so `tsc: not found` survives it and the stale dist is
# served forever. Reinstall (non-silent, so the user sees it) first.
missing_tool = _missing_web_build_tool((r2.stdout or "") + (r2.stderr or ""))
if missing_tool:
_say(f" ⚠ Build could not resolve {missing_tool} — reinstalling web dependencies...")
_install_web_deps(silent=False)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# Retry once after a short delay — covers boot-time races on Windows
# (antivirus scanning Node.js binaries, npm cache not ready, transient
# I/O when launched via Scheduled Task at logon). See issue #23817.
_time.sleep(3)
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env)
if r2.returncode != 0:
# _run_with_idle_timeout merges stderr into stdout; older callers
# using subprocess.run kept them split. Pull from whichever has
# content so the error surfaces regardless of which path produced
# the CompletedProcess.
build_output = (r2.stderr or "") + (r2.stdout or "")
stderr_preview = build_output.strip()
stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else ""
project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
dist_dir = project_root / "hermes_cli" / "web_dist"
dist_index = dist_dir / "index.html"
# If a stale dist exists, serve it as a fallback instead of failing.
# A stale UI is far better than no UI for non-interactive callers
# (Windows Scheduled Tasks, CI) — issue #23817.
if dist_index.exists():
_say(" ⚠ Web UI build failed — serving stale dist as fallback")
if stderr_tail:
_say(f" Build error:\n {stderr_tail}")
return True
_say(
f" {'✗' if fatal else '⚠'} Web UI build failed"
+ ("" if fatal else " (hermes web will not be available)")
)
_relay(r2)
if fatal:
_say(" Run manually: npm install --workspace web && npm run build -w web")
return False
_say(" ✓ Web UI built")
project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
_write_web_ui_build_stamp(project_root, web_dir)
return True