728 lines
30 KiB
Python
728 lines
30 KiB
Python
"""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
|