Files
unsupportedpastels 3f2b1722b1 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.
2026-10-27 09:42:22 -04:00

172 lines
8.7 KiB
Python

"""Assemble a runnable agent from prepared inputs, without downloads or live state."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import tomllib
from scripts.build.inputs import AgentInputs, RESOURCE_ENV, project_entries, validate_frontends
from scripts.build.launchers import write_launchers
def _copy(source: Path, destination: Path, *, replace: bool = False) -> None:
if source.resolve() == destination.resolve():
return
if destination.resolve().is_relative_to(source.resolve()) or source.resolve().is_relative_to(destination.resolve()):
raise ValueError(f"output must not be inside copied input: {source}")
if replace and destination.exists():
shutil.rmtree(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, destination, symlinks=True, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", ".git"))
else:
shutil.copy2(source, destination)
def plant_surfaces(repo: Path, frontends: dict[str, Path]) -> None:
"""Only explicit built products are accepted; no hunting in the source tree."""
validate_frontends(frontends)
if "tui" in frontends:
tui = frontends["tui"]
destination = repo / "hermes_cli/tui_dist"
_copy(tui / "dist", destination, replace=True)
_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:
"""Source-layout installs have no wheel; preserve PEP 621 dependency metadata.
This attaches the project's static declarations, not installed-environment
guesses. Nix keeps the real wheel's dist-info untouched.
"""
project = tomllib.loads(project_path.read_text(encoding="utf-8-sig"))["project"]
if project.get("dynamic"):
raise ValueError("agent assembly requires static project metadata")
dist = re.sub(r"[-_.]+", "_", project["name"])
info = repo / f"{dist}-{project['version']}.dist-info"
info.mkdir(parents=True, exist_ok=True)
lines = ["Metadata-Version: 2.3", f"Name: {project['name']}", f"Version: {project['version']}"]
for key, header in (("description", "Summary"), ("requires-python", "Requires-Python")):
if key in project:
lines.append(f"{header}: {project[key]}")
lines.extend(f"Requires-Dist: {req}" for req in project.get("dependencies", []))
for extra, requirements in project.get("optional-dependencies", {}).items():
extra = re.sub(r"[-_.]+", "-", extra).lower()
lines.append(f"Provides-Extra: {extra}")
for req in requirements:
requirement, _, marker = req.partition(";")
condition = f"({marker.strip()}) and " if marker else ""
lines.append(f"Requires-Dist: {requirement.strip()}; {condition}extra == '{extra}'")
(info / "METADATA").write_text("\n".join(lines) + "\n", encoding="utf-8")
groups = {**project.get("entry-points", {}), "console_scripts": project_entries(project_path)}
if project.get("gui-scripts"):
groups["gui_scripts"] = project["gui-scripts"]
text = "\n".join(f"[{group}]\n" + "\n".join(f"{name} = {entry}" for name, entry in values.items())
for group, values in groups.items())
(info / "entry_points.txt").write_text(text + "\n", encoding="utf-8")
def assemble(inputs: AgentInputs, out: Path) -> dict:
out = out.absolute()
out.mkdir(parents=True, exist_ok=True)
# An interrupted rebuild must not leave an old completion claim behind.
manifest_path = out / "manifest.json"
previous = json.loads(manifest_path.read_text(encoding="utf-8-sig")) if manifest_path.is_file() else {}
manifest_path.unlink(missing_ok=True)
(out / "command-map.json").unlink(missing_ok=True)
inputs.validate(out)
entries = project_entries(inputs.project)
repo = out / inputs.repo
referenced = inputs.placement == "references"
repo.mkdir(parents=True, exist_ok=True)
if not referenced:
if inputs.code.resolve() != repo.resolve():
if repo == out:
raise ValueError("root-level code must be prepared in place")
_copy(inputs.code, repo, replace=True)
for name, source in inputs.resources.items():
_copy(source, repo / name, replace=True)
plant_surfaces(repo, inputs.frontends)
write_metadata(inputs.project, repo)
# Arbitrary scripts run with the prepared environment's Python too
# (Docker's config migration, plugins), not only generated launchers.
if not inputs.site_packages.resolve().is_relative_to(out):
raise ValueError("source binding requires an output-owned dependency directory")
relative_code = Path(os.path.relpath(repo, inputs.site_packages)).as_posix()
(inputs.site_packages / "hermes-agent.pth").write_text(relative_code + "\n", encoding="utf-8")
for source, destination in ((inputs.stamp, repo / "install-stamp.json"),
(inputs.features, out / "enabled-features.json")):
if source is not None:
_copy(source, destination)
if inputs.placement == "contained":
from scripts.bundles.payload import relativize_links
relativize_links(out)
def location(path: Path) -> str:
return path.relative_to(out).as_posix() if path.is_relative_to(out) and not referenced else str(path)
python, site = location(inputs.python), location(inputs.site_packages)
tools = location(inputs.tools) if inputs.tools else "tools"
if referenced:
commands = {name: str(inputs.command_dir / name) for name in entries}
for command in commands.values():
if not Path(command).is_file():
raise FileNotFoundError(f"prepared command missing: {command}")
env = {**inputs.env, "HERMES_PYTHON": str(inputs.python), "HERMES_INSTALL_ROOT": str(repo)}
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"),
"webapp": ("HERMES_WEBAPP_DIST", repo / "webapp_dist")}[name]
links[key] = (path, destination)
for key, (source, destination) in links.items():
destination.unlink(missing_ok=True)
destination.symlink_to(source, target_is_directory=True)
env[key] = str(destination)
mapping = {"commands": {name: {"source": commands[name], "destination": f"{inputs.bin_dir}/{name}",
"entry": entry} for name, entry in entries.items()}, "env": env}
(out / "command-map.json").write_text(json.dumps(mapping, indent=2) + "\n", encoding="utf-8")
else:
for name, command in previous.get("runtime", {}).get("commands", {}).items():
if name not in entries and command == f"{inputs.bin_dir}/{name}{'.exe' if inputs.target.startswith('win32') else ''}":
(out / command).unlink(missing_ok=True)
launch_python = f"{location(inputs.environment)}/bin/python" if inputs.target.endswith("-bionic") else python
commands = write_launchers(out, entries, python=launch_python, repo=inputs.repo, site=site,
target=inputs.target, bin_dir=inputs.bin_dir)
manifest = {"schema": 1, "target": inputs.target, "repo": inputs.repo,
"venv": location(inputs.environment), "store": tools,
"launchers": list(entries), "runtime": {
"repoDir": str(inputs.code) if referenced else inputs.repo,
"toolsDir": tools, "storePython": python,
"sitePackages": site, "commands": commands}}
if inputs.ref is not None:
manifest["ref"] = inputs.ref
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inputs", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
try:
inputs = AgentInputs.from_dict(json.loads(args.inputs.read_text(encoding="utf-8-sig")))
assemble(inputs, args.out)
except Exception:
(args.out / "manifest.json").unlink(missing_ok=True)
(args.out / "command-map.json").unlink(missing_ok=True)
raise
if __name__ == "__main__":
main()