mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(nix): ship the Webapp in the Nix package
The Nix package had no Webapp bundle, and `hermes webapp` could not build one in the read-only store. nix/webapp.nix builds it in the npm workspace the hook prepares (no native rebuild, no Electron), exposed as `.#webapp`. The agent assembler takes it as a `webapp` frontend: the wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the dashboard. hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no freshness check, no build lock, never rebuilt or marked stale, and `--force-build` says it cannot rebuild it.
This commit is contained in:
1 parent
51c8cba024
commit
3f2b1722b1
12 files changed
+139
-10
No files matched your search
+22
-3
@@ -10,6 +10,7 @@ receipt inside ``dist-webapp`` decides freshness.
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -21,21 +22,31 @@ _LOCK_WAIT_SECONDS = 30 * 60
|
||||
# replaces the whole folder, and the receipt's output hash covers this file, so a marked bundle is
|
||||
# never reused as current.
|
||||
STALE_MARKER = ".hermes-stale"
|
||||
# A packaged bundle (the Nix package), like HERMES_WEB_DIST for the dashboard: the packager
|
||||
# builds it, so Hermes serves it as it is and never builds or marks it.
|
||||
DIST_ENV = "HERMES_WEBAPP_DIST"
|
||||
|
||||
|
||||
class WebappBuildError(RuntimeError):
|
||||
"""The browser-hosted Desktop renderer could not be prepared."""
|
||||
|
||||
|
||||
def packaged_webapp_dist() -> Path | None:
|
||||
"""The bundle :data:`DIST_ENV` names, or None when Hermes builds its own."""
|
||||
value = os.environ.get(DIST_ENV)
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def webapp_dist_dir(project_root: Path) -> Path:
|
||||
return project_root / "apps" / "desktop" / "dist-webapp"
|
||||
return packaged_webapp_dist() or project_root / "apps" / "desktop" / "dist-webapp"
|
||||
|
||||
|
||||
def mark_webapp_stale(project_root: Path) -> bool:
|
||||
"""Mark the built bundle in *project_root* out of date, after a rebuild that failed. False when
|
||||
no bundle is built: nothing serves one, and the build output folder stays the builder's."""
|
||||
no bundle is built: nothing serves one, and the build output folder stays the builder's. Also
|
||||
False for a packaged bundle, which is the packager's."""
|
||||
dist = webapp_dist_dir(project_root)
|
||||
if not (dist / "index.html").is_file():
|
||||
if packaged_webapp_dist() is not None or not (dist / "index.html").is_file():
|
||||
return False
|
||||
(dist / STALE_MARKER).write_text("hermes update could not rebuild this bundle\n", encoding="utf-8")
|
||||
return True
|
||||
@@ -94,6 +105,14 @@ def prepare_webapp_renderer(
|
||||
|
||||
project_root = project_root.resolve()
|
||||
dist = webapp_dist_dir(project_root)
|
||||
if packaged_webapp_dist() is not None:
|
||||
# The packager built it into a read-only store; there is nothing here to rebuild.
|
||||
if not (dist / "index.html").is_file():
|
||||
raise WebappBuildError(f"{DIST_ENV} is set but no Webapp renderer exists at {dist}")
|
||||
if force:
|
||||
raise WebappBuildError(f"The Webapp renderer at {dist} comes from {DIST_ENV} and cannot be rebuilt here")
|
||||
print(f"→ Using Hermes Webapp renderer from {DIST_ENV}: {dist}")
|
||||
return dist
|
||||
if skip_build:
|
||||
# Reuse takes no build lock, like `hermes dashboard --skip-build`: the
|
||||
# lock lives in the install root, which a read-only install cannot write.
|
||||
|
||||
@@ -352,7 +352,7 @@ def _explain_refused_launch(target: Path, response: dict) -> int:
|
||||
|
||||
def cmd_webapp(args) -> int:
|
||||
from gateway.webapp_listener import webapp_settings
|
||||
from hermes_cli.webapp import WebappBuildError
|
||||
from hermes_cli.webapp import WebappBuildError, packaged_webapp_dist
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
@@ -371,8 +371,10 @@ def cmd_webapp(args) -> int:
|
||||
_build(args)
|
||||
except WebappBuildError as exc:
|
||||
print(f"✗ {exc}")
|
||||
print(" Build it explicitly:")
|
||||
print(" hermes webapp --build-only")
|
||||
# A packaged bundle (HERMES_WEBAPP_DIST) is never built here, so no build can fix it.
|
||||
if packaged_webapp_dist() is None:
|
||||
print(" Build it explicitly:")
|
||||
print(" hermes webapp --build-only")
|
||||
return 1
|
||||
if args.build_only:
|
||||
print("Webapp bundle ready.")
|
||||
|
||||
@@ -128,6 +128,11 @@ let
|
||||
inherit hermesNpmLib generatedIcons;
|
||||
};
|
||||
|
||||
# The store is read-only, so the Webapp is built here and never by `hermes webapp`.
|
||||
hermesWebapp = callPackage ./webapp.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
||||
bundledSkills = lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
@@ -225,6 +230,7 @@ let
|
||||
frontends = {
|
||||
tui = "${hermesTui}/lib/hermes-tui";
|
||||
web = toString hermesWeb;
|
||||
webapp = toString hermesWebapp;
|
||||
};
|
||||
ref = if dirty then null else rev;
|
||||
stamp = toString installStampFile;
|
||||
@@ -387,6 +393,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesWebapp
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
agentBuilderSrc
|
||||
|
||||
+1
-1
@@ -331,7 +331,7 @@ in
|
||||
|
||||
# importNpmLock reads hashes from the lockfile itself — rebuild every
|
||||
# npm package to verify the new lockfile resolves offline.
|
||||
nix build .#tui .#web .#desktop
|
||||
nix build .#tui .#web .#webapp .#desktop
|
||||
echo "Lockfile updated and all npm packages built."
|
||||
'';
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
|
||||
tui = full.hermesTui;
|
||||
web = full.hermesWeb;
|
||||
webapp = full.hermesWebapp;
|
||||
desktop = full.hermesDesktop;
|
||||
|
||||
update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile;
|
||||
|
||||
@@ -20,7 +20,7 @@ for name, source in inputs["resources"].items():
|
||||
resource = repo / name
|
||||
assert resource.is_symlink(), resource
|
||||
assert resource.samefile(source), (resource, source)
|
||||
for name, key in (("tui", "HERMES_TUI_DIR"), ("web", "HERMES_WEB_DIST")):
|
||||
for name, key in (("tui", "HERMES_TUI_DIR"), ("web", "HERMES_WEB_DIST"), ("webapp", "HERMES_WEBAPP_DIST")):
|
||||
resource = Path(command_map["env"][key])
|
||||
assert resource.is_symlink(), resource
|
||||
assert resource.samefile(inputs["frontends"][name])
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# The Webapp: the Desktop renderer built for a browser. The npm hook has
|
||||
# already installed the locked workspace, so it compiles there, with no
|
||||
# private workspace and no Electron.
|
||||
{ hermesNpmLib, ... }:
|
||||
hermesNpmLib.buildNpmPackage {
|
||||
dirs = [
|
||||
"apps/desktop"
|
||||
"apps/shared"
|
||||
"scripts/build/webapp.mjs"
|
||||
"scripts/build/node-deps.mjs"
|
||||
"scripts/build/freshness.mjs"
|
||||
"scripts/build/frontend-common.mjs"
|
||||
];
|
||||
pname = "hermes-webapp";
|
||||
|
||||
# The browser bundle loads no native module (node-pty belongs to Desktop),
|
||||
# so none is compiled.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
node --input-type=module -e '
|
||||
import { buildWebapp } from "./scripts/build/webapp.mjs"
|
||||
await buildWebapp({ source: process.cwd(), workspace: process.cwd(), out: `''${process.env.TMPDIR}/webapp-product` })
|
||||
'
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -r "$TMPDIR/webapp-product/." $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
@@ -38,6 +38,8 @@ def plant_surfaces(repo: Path, frontends: dict[str, Path]) -> None:
|
||||
_copy(tui / "package.json", destination / "package.json")
|
||||
if "web" in frontends:
|
||||
_copy(frontends["web"], repo / "hermes_cli/web_dist", replace=True)
|
||||
if "webapp" in frontends:
|
||||
_copy(frontends["webapp"], repo / "apps/desktop/dist-webapp", replace=True)
|
||||
|
||||
|
||||
def write_metadata(project_path: Path, repo: Path) -> None:
|
||||
@@ -122,7 +124,8 @@ def assemble(inputs: AgentInputs, out: Path) -> dict:
|
||||
links = {RESOURCE_ENV[name]: (path, repo / name) for name, path in inputs.resources.items()}
|
||||
for name, path in inputs.frontends.items():
|
||||
key, destination = {"tui": ("HERMES_TUI_DIR", out / "ui-tui"),
|
||||
"web": ("HERMES_WEB_DIST", repo / "web_dist")}[name]
|
||||
"web": ("HERMES_WEB_DIST", repo / "web_dist"),
|
||||
"webapp": ("HERMES_WEBAPP_DIST", repo / "webapp_dist")}[name]
|
||||
links[key] = (path, destination)
|
||||
for key, (source, destination) in links.items():
|
||||
destination.unlink(missing_ok=True)
|
||||
|
||||
@@ -80,7 +80,7 @@ class AgentInputs:
|
||||
raise ValueError("bin_dir must be one output-relative directory")
|
||||
if self.resources.keys() - RESOURCE_ENV.keys():
|
||||
raise ValueError("unknown resource names")
|
||||
if self.frontends.keys() - {"tui", "web"}:
|
||||
if self.frontends.keys() - {"tui", "web", "webapp"}:
|
||||
raise ValueError("unknown frontend names")
|
||||
paths = [self.project, self.code, self.python, self.site_packages, self.environment,
|
||||
self.pm_runtime, *self.resources.values(), *self.frontends.values()]
|
||||
|
||||
@@ -76,6 +76,32 @@ def test_a_current_bundle_is_reused_without_writing_the_install_tree(tmp_path: P
|
||||
assert sorted(tmp_path.rglob("*")) == install_tree
|
||||
|
||||
|
||||
def test_a_packaged_bundle_is_served_as_shipped(tmp_path: Path, monkeypatch):
|
||||
"""The Nix package points HERMES_WEBAPP_DIST at a bundle in the read-only store: it is used
|
||||
without a freshness check or the build lock, never rebuilt and never marked stale."""
|
||||
from hermes_cli import source_build
|
||||
|
||||
_workspace_tree(tmp_path)
|
||||
packaged = tmp_path / "store" / "webapp"
|
||||
packaged.mkdir(parents=True)
|
||||
(packaged / "index.html").write_text("<!doctype html>", encoding="utf-8")
|
||||
monkeypatch.setenv(webapp.DIST_ENV, str(packaged))
|
||||
monkeypatch.setattr(source_build, "source_product_current", lambda *_a: pytest.fail("no freshness check"))
|
||||
monkeypatch.setattr(source_build, "build_source_webapp", lambda *_a, **_k: pytest.fail("no build"))
|
||||
|
||||
assert webapp.webapp_dist_dir(tmp_path) == packaged
|
||||
install_tree = sorted(tmp_path.rglob("*"))
|
||||
assert webapp.prepare_webapp_renderer(tmp_path) == packaged
|
||||
assert webapp.mark_webapp_stale(tmp_path) is False
|
||||
assert sorted(tmp_path.rglob("*")) == install_tree
|
||||
with pytest.raises(webapp.WebappBuildError, match="cannot be rebuilt"):
|
||||
webapp.prepare_webapp_renderer(tmp_path, force=True)
|
||||
|
||||
(packaged / "index.html").unlink()
|
||||
with pytest.raises(webapp.WebappBuildError, match="HERMES_WEBAPP_DIST is set"):
|
||||
webapp.prepare_webapp_renderer(tmp_path)
|
||||
|
||||
|
||||
def _assert_build_lock_excludes_second_open(tmp_path: Path):
|
||||
from pm.filesystem import lock_fd, unlock_fd
|
||||
|
||||
|
||||
@@ -54,6 +54,15 @@ def inputs_fixture(tmp_path):
|
||||
}
|
||||
|
||||
|
||||
def with_webapp(data, tmp_path):
|
||||
"""Add a built Webapp renderer, the frontend only the Nix package passes today."""
|
||||
webapp = tmp_path / "webapp"
|
||||
webapp.mkdir()
|
||||
(webapp / "index.html").write_text("built webapp", encoding="utf-8")
|
||||
data["frontends"] = {**data["frontends"], "webapp": str(webapp)}
|
||||
return data
|
||||
|
||||
|
||||
def build_cli(data, out, tmp_path):
|
||||
document = tmp_path / "inputs.json"
|
||||
document.write_text(json.dumps(data), encoding="utf-8")
|
||||
@@ -71,6 +80,7 @@ def build_cli(data, out, tmp_path):
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp_path):
|
||||
out, data = inputs_fixture(tmp_path)
|
||||
with_webapp(data, tmp_path)
|
||||
(out / "provider-state").write_text("retain", encoding="utf-8")
|
||||
before = {p.relative_to(data["code"]): p.read_bytes() for p in Path(data["code"]).rglob("*") if p.is_file()}
|
||||
result = build_cli(data, out, tmp_path)
|
||||
@@ -84,6 +94,7 @@ def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp
|
||||
assert (out / "app/hermes_cli/tui_dist/entry.js").is_file()
|
||||
assert json.loads((out / "app/hermes_cli/tui_dist/package.json").read_text())["type"] == "module"
|
||||
assert (out / "app/hermes_cli/web_dist/index.html").is_file()
|
||||
assert (out / "app/apps/desktop/dist-webapp/index.html").read_text() == "built webapp"
|
||||
assert not (tmp_path / "home/.hermes").exists()
|
||||
moved = tmp_path / "relocated payload"
|
||||
out.rename(moved)
|
||||
@@ -102,6 +113,7 @@ def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_referenced_installed_code_uses_derived_command_map_without_copying(tmp_path):
|
||||
_, data = inputs_fixture(tmp_path)
|
||||
with_webapp(data, tmp_path)
|
||||
out = tmp_path / "nix output"
|
||||
source = Path(data["code"])
|
||||
(source / "entry.py").write_text(
|
||||
@@ -123,11 +135,13 @@ def test_referenced_installed_code_uses_derived_command_map_without_copying(tmp_
|
||||
"source": str(command), "destination": "bin/probe", "entry": "entry:main"}}
|
||||
assert Path(mapping["env"]["HERMES_TUI_DIR"]).resolve() == Path(data["frontends"]["tui"])
|
||||
assert Path(mapping["env"]["HERMES_WEB_DIST"]).resolve() == Path(data["frontends"]["web"])
|
||||
assert Path(mapping["env"]["HERMES_WEBAPP_DIST"]).resolve() == Path(data["frontends"]["webapp"])
|
||||
assert mapping["env"]["HERMES_INSTALL_ROOT"] == str(out / "share/hermes-agent")
|
||||
assert not list(out.rglob("entry.py"))
|
||||
assert (out / "share/hermes-agent/skills").is_symlink()
|
||||
assert (out / "ui-tui").is_symlink()
|
||||
assert (out / "share/hermes-agent/web_dist").is_symlink()
|
||||
assert (out / "share/hermes-agent/webapp_dist").is_symlink()
|
||||
assert not (out / "bin/probe").exists(), "Nix owns native makeWrapper"
|
||||
run = subprocess.run([mapping["commands"]["probe"]["source"]], cwd=tmp_path,
|
||||
env={"PATH": os.environ["PATH"], "HOME": str(tmp_path / "home"),
|
||||
|
||||
@@ -696,6 +696,26 @@ macOS has no equivalent option. A `launchd` agent with `RunAtLoad` starts at log
|
||||
The default address is `127.0.0.1`. Each other address starts the authentication gate of the dashboard. The server also refuses each request with a `Host` header that is different from the address that the server bound to. This is a defence against DNS rebinding. Bind to the name or the address that your client uses.
|
||||
:::
|
||||
|
||||
### Running the Webapp
|
||||
|
||||
The package ships the [Webapp](../user-guide/desktop.md#use-the-desktop-workspace-from-a-browser) (Hermes Desktop in a browser) prebuilt, so nothing is built at run time; the store is read-only. The gateway serves it when its settings turn it on (see [Configuration → Webapp](../user-guide/configuration.md#webapp)):
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
gateway.enable = true;
|
||||
settings.webapp = {
|
||||
enabled = true;
|
||||
host = "127.0.0.1";
|
||||
port = 9120;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Open it with `hermes webapp`. A non-loopback `host` or a `public_url` needs one of the [dashboard's sign-in providers](../user-guide/features/web-dashboard.md#authentication-gated-mode), as for the dashboard. `nix build .#webapp` builds the bundle on its own. The wrapper points `HERMES_WEBAPP_DIST` at it, so `hermes webapp --force-build` has nothing to rebuild and says so.
|
||||
|
||||
### Verify It Works
|
||||
|
||||
```bash
|
||||
|
||||
Reference in new issue
Block a user