Files
hermes-agent/scripts/build/inputs.py
teknium1 a3f454a287 fix(build): keep scripts/build/inputs.py free of pm imports; accept musl targets in its grammar
The Nix agent derivation builds scripts/build/*.py from a fileset that
does not include pm/, so importing pm.store.ALL_TARGETS there failed
nix flake check with ModuleNotFoundError. Extend the local target
regex with linux-(x64|arm64)-musl instead.
2026-09-27 03:24:39 -07:00

123 lines
5.7 KiB
Python

"""Build-time paths, not a second runtime configuration or dependency resolver."""
from __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
import json
import re
import tomllib
RESOURCE_ENV = {
"skills": "HERMES_BUNDLED_SKILLS",
"optional-skills": "HERMES_OPTIONAL_SKILLS",
"plugins": "HERMES_BUNDLED_PLUGINS",
"locales": "HERMES_BUNDLED_LOCALES",
"optional-mcps": "HERMES_OPTIONAL_MCPS",
}
def dependency_site(environment: Path, python_version: str, target: str) -> Path:
"""Providers know the target pin; the builder's interpreter is irrelevant."""
minor = python_version.split("+")[0].rsplit(".", 1)[0]
return environment / ("Lib/site-packages" if target.startswith("win32") else
f"lib/python{minor}/site-packages")
def project_entries(project: Path) -> dict[str, str]:
entries = tomllib.loads(project.read_text(encoding="utf-8-sig"))["project"]["scripts"]
for name, entry in entries.items():
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name):
raise ValueError(f"invalid command name: {name}")
if not re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", entry):
raise ValueError(f"invalid script entry: {entry}")
return entries
@dataclass(frozen=True)
class AgentInputs:
project: Path
code: Path
repo: str
placement: str
target: str
python: Path
site_packages: Path
environment: Path
pm_runtime: Path
bin_dir: str = "bin"
tools: Path | None = None
command_dir: Path | None = None
resources: dict[str, Path] = field(default_factory=dict)
frontends: dict[str, Path] = field(default_factory=dict)
ref: str | None = None
stamp: Path | None = None
features: Path | None = None
env: dict[str, str] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict) -> AgentInputs:
unknown = data.keys() - {f.name for f in fields(cls)}
if unknown:
raise ValueError(f"unknown agent inputs: {', '.join(sorted(unknown))}")
values = dict(data)
for key in ("project", "code", "python", "site_packages", "environment", "tools",
"pm_runtime", "command_dir", "stamp", "features"):
if values.get(key) is not None:
values[key] = Path(values[key])
for key in ("resources", "frontends"):
values[key] = {name: Path(path) for name, path in values.get(key, {}).items()}
return cls(**values)
def validate(self, out: Path) -> None:
if not re.fullmatch(r"(?:(?:linux|darwin|win32)-(?:x64|arm64)|linux-(?:x64|arm64)-musl|linux-arm64-bionic)", self.target):
raise ValueError(f"unsupported target: {self.target}")
if self.placement not in ("contained", "fixed", "references"):
raise ValueError(f"unknown placement: {self.placement}")
if Path(self.repo).is_absolute() or ".." in Path(self.repo).parts:
raise ValueError("repo must be an output-relative path")
if not re.fullmatch(r"[A-Za-z0-9_-]+", self.bin_dir):
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"}:
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()]
paths += [p for p in (self.tools, self.command_dir, self.stamp, self.features) if p is not None]
for path in paths:
if not path.is_absolute():
raise ValueError(f"input path must be absolute: {path}")
if not path.exists():
raise FileNotFoundError(f"required agent input missing: {path}")
for path in (self.project, self.python):
if not path.is_file():
raise ValueError(f"expected a file: {path}")
for path in (self.code, self.site_packages, self.environment, self.pm_runtime):
if not path.is_dir():
raise ValueError(f"expected a directory: {path}")
marker = json.loads((self.pm_runtime / "pm-runtime.json").read_text(encoding="utf-8-sig"))
pm_python = (self.pm_runtime / marker["python"]).resolve()
pm_site = (self.pm_runtime / marker["sitePackages"]).resolve()
if not pm_python.is_file() or not pm_site.is_dir():
raise ValueError("prepared PM runtime is incomplete")
if self.stamp is not None:
stamp = json.loads(self.stamp.read_text(encoding="utf-8-sig"))
if stamp.get("distribution") in ("nix", "docker") and stamp.get("pmRuntime") != str(self.pm_runtime):
raise ValueError("install stamp must reference the supplied independent PM runtime")
if self.placement == "contained":
for path in (self.python, self.site_packages, self.environment, self.pm_runtime, self.tools):
if path is not None and not path.resolve().is_relative_to(out):
raise ValueError(f"contained input must be prepared inside output: {path}")
if self.placement == "references" and self.command_dir is None:
raise ValueError("referenced placement requires command_dir")
validate_frontends(self.frontends)
def validate_frontends(frontends: dict[str, Path]) -> None:
for name, path in frontends.items():
required = ("dist/entry.js", "package.json") if name == "tui" else ("index.html",)
for entry in required:
if not (path / entry).is_file():
raise FileNotFoundError(path / entry)