Compare commits
13 Commits
bb/gui
...
salvage/27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7452d5b77e | ||
|
|
875d930ac7 | ||
|
|
b924b22a9d | ||
|
|
4a6f1863ac | ||
|
|
66489f38c7 | ||
|
|
ebe04c66cd | ||
|
|
6d947e4d78 | ||
|
|
10f13c3881 | ||
|
|
c9410b3462 | ||
|
|
c341a2d107 | ||
|
|
71b4a6b18e | ||
|
|
aeb992d343 | ||
|
|
b345323195 |
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -71,6 +71,8 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
@@ -149,6 +151,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
@@ -203,6 +207,8 @@ jobs:
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
@@ -228,6 +234,8 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
46
Dockerfile
46
Dockerfile
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -187,6 +187,29 @@ RUN chmod -R a+rX /opt/hermes && \
|
||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# ---------- Bake build-time git revision ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# container always returns nothing — meaning `hermes dump` reports
|
||||
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
||||
# That makes support triage from container bug reports impossible:
|
||||
# we can't tell which commit the user is actually running.
|
||||
#
|
||||
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
||||
# /opt/hermes/.hermes_build_sha at build time, and have
|
||||
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
||||
# banner.get_git_banner_state() try the baked SHA first, then fall back
|
||||
# to live `git rev-parse` for source installs (unchanged behaviour).
|
||||
#
|
||||
# The arg is optional — local `docker build` without --build-arg simply
|
||||
# omits the file, and the runtime falls back to live-git lookup. CI
|
||||
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
|
||||
# every published image has it.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
# Static services declared at build time: main-hermes + dashboard.
|
||||
# Per-profile gateway services are registered dynamically at runtime by
|
||||
@@ -213,13 +236,32 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
|
||||
# up root-owned and unreadable to the supervised gateway (UID 10000).
|
||||
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
|
||||
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
|
||||
# when invoked as root. Non-root callers (supervised processes,
|
||||
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
|
||||
# Recursion is impossible because the shim exec's the venv binary by
|
||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
||||
|
||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||
# same for the container's main process, but `docker exec` and our
|
||||
# cont-init.d scripts don't pass through the wrapper. Expose the venv
|
||||
# bin globally so `docker exec <container> hermes ...` and any
|
||||
# subprocess that doesn't activate the venv first still find hermes.
|
||||
ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
#
|
||||
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
|
||||
# shim wins PATH resolution. The shim's last act is to exec the venv
|
||||
# binary by absolute path, so this PATH ordering is transparent to
|
||||
# every other consumer.
|
||||
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
|
||||
RUN mkdir -p /opt/data
|
||||
VOLUME [ "/opt/data" ]
|
||||
|
||||
|
||||
@@ -483,6 +483,11 @@ def _run_review_in_thread(
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
# Snapshot review actions before teardown. close() is allowed to
|
||||
# clean per-session state, but the user-visible self-improvement
|
||||
# summary still needs the completed review agent's tool results.
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
|
||||
# Tear down memory providers while stdout is still
|
||||
# redirected so background thread teardown (Honcho flush,
|
||||
# Hindsight sync, etc.) stays silent. The finally block
|
||||
@@ -495,7 +500,6 @@ def _run_review_in_thread(
|
||||
review_agent.close()
|
||||
except Exception:
|
||||
pass
|
||||
review_messages = list(getattr(review_agent, "_session_messages", []))
|
||||
review_agent = None
|
||||
|
||||
# Scan the review agent's messages for successful tool actions
|
||||
|
||||
87
docker/hermes-exec-shim.sh
Normal file
87
docker/hermes-exec-shim.sh
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/bin/sh
|
||||
# shellcheck shell=sh
|
||||
# /opt/hermes/bin/hermes — `docker exec` privilege-drop shim.
|
||||
#
|
||||
# Background
|
||||
# ----------
|
||||
# The s6 image runs the supervised gateway/main process as the unprivileged
|
||||
# `hermes` user (UID 10000). When an operator runs `docker exec <c> hermes ...`
|
||||
# the default UID is root (0), and any file the command writes under
|
||||
# $HERMES_HOME — auth.json, .env, config.yaml — ends up root-owned and
|
||||
# unreadable to the supervised gateway. The most common manifestation: the
|
||||
# user runs `docker exec <c> hermes login`, this writes
|
||||
# /opt/data/auth.json as root:root mode 0600, and from then on the gateway
|
||||
# returns "Provider authentication failed: Hermes is not logged into Nous
|
||||
# Portal" on every incoming message — even though `docker exec <c> hermes
|
||||
# chat -q ping` (also running as root) succeeds because root happens to be
|
||||
# able to read its own root-owned file. See systematic-debugging skill
|
||||
# notes attached to this fix.
|
||||
#
|
||||
# Fix
|
||||
# ---
|
||||
# This shim sits at /opt/hermes/bin/hermes and is placed earliest on PATH.
|
||||
# When invoked as root, it drops to the hermes user (via s6-setuidgid)
|
||||
# before exec'ing the real venv binary, so anything that writes under
|
||||
# $HERMES_HOME is uid-aligned with the supervised processes. When invoked
|
||||
# as any non-root UID — including the supervised processes themselves,
|
||||
# `docker exec --user hermes`, kanban subagents, etc. — it short-circuits
|
||||
# straight to the venv binary with no privilege change. Net: one extra
|
||||
# fork on the docker-exec-as-root path, zero behavioral change on every
|
||||
# other path.
|
||||
#
|
||||
# Recursion safety: the shim exec's the venv binary by *absolute path*
|
||||
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
|
||||
# shim regardless of PATH state. No sentinel env var needed.
|
||||
#
|
||||
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
|
||||
# to keep running as root. Reserved for diagnostic sessions where the
|
||||
# operator deliberately wants root semantics — e.g. inspecting root-only
|
||||
# state via the hermes CLI. Default is to drop.
|
||||
|
||||
set -e
|
||||
|
||||
REAL=/opt/hermes/.venv/bin/hermes
|
||||
|
||||
# Defensive: if the venv binary is missing (corrupted image, partial
|
||||
# install), fail loudly rather than silently masking it.
|
||||
if [ ! -x "$REAL" ]; then
|
||||
echo "hermes-shim: $REAL not found or not executable" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Already non-root? Just exec the real binary. This is the hot path for
|
||||
# supervised processes (uid 10000) and for `docker exec --user hermes`.
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
# Root, with opt-out set? Honor it.
|
||||
case "${HERMES_DOCKER_EXEC_AS_ROOT:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes)
|
||||
exec "$REAL" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Root, no opt-out. Drop to the hermes user.
|
||||
#
|
||||
# s6-setuidgid lives under /command/ which is NOT on `docker exec`'s PATH
|
||||
# (s6-overlay only puts /command/ on PATH for supervision-tree children).
|
||||
# Reference it by absolute path so the drop is robust against PATH
|
||||
# manipulation.
|
||||
S6_SUID=/command/s6-setuidgid
|
||||
if [ ! -x "$S6_SUID" ]; then
|
||||
# Non-s6 image (someone stripped s6-overlay, or a hand-built variant).
|
||||
# Fail loud rather than silently re-execing as root and leaking the
|
||||
# bug this shim exists to prevent.
|
||||
echo "hermes-shim: $S6_SUID not found; refusing to silently run as root." >&2
|
||||
echo "hermes-shim: re-run with --user hermes or set HERMES_DOCKER_EXEC_AS_ROOT=1." >&2
|
||||
exit 126
|
||||
fi
|
||||
|
||||
# Reset HOME to the hermes user's home before dropping privileges. Without
|
||||
# this, $HOME stays /root and any library that resolves paths off $HOME
|
||||
# (XDG caches, lockfiles, .config writes) will try to write to /root and
|
||||
# fail with EACCES. Mirrors main-wrapper.sh.
|
||||
export HOME=/opt/data
|
||||
|
||||
exec "$S6_SUID" hermes "$REAL" "$@"
|
||||
@@ -19,6 +19,10 @@ case "${HERMES_DASHBOARD:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# with-contenv repopulates HOME from /init as /root. Reset it before
|
||||
# dropping privileges so HOME-anchored state lands under /opt/data.
|
||||
export HOME=/opt/data
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
@@ -300,14 +300,42 @@ def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
|
||||
|
||||
|
||||
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
|
||||
"""Return upstream/local git hashes for the startup banner."""
|
||||
"""Return upstream/local git hashes for the startup banner.
|
||||
|
||||
For source installs and dev images this runs ``git rev-parse`` against
|
||||
the active checkout. When no checkout is available — the canonical case
|
||||
is the published Docker image, which excludes ``.git`` from the build
|
||||
context — we fall back to the baked-in build SHA (see
|
||||
``hermes_cli/build_info.py``) and return it as a frozen
|
||||
``upstream == local`` state with ``ahead=0``. A built image is by
|
||||
definition pinned to one commit, so "ahead" is always zero and the
|
||||
banner correctly shows ``· upstream <sha>`` with no carried-commits
|
||||
annotation.
|
||||
"""
|
||||
repo_dir = repo_dir or _resolve_repo_dir()
|
||||
if repo_dir is None:
|
||||
# No git checkout — try the baked build SHA (Docker image path).
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
upstream = _git_short_hash(repo_dir, "origin/main")
|
||||
local = _git_short_hash(repo_dir, "HEAD")
|
||||
if not upstream or not local:
|
||||
# Live-git lookup failed (e.g. shallow clone without origin/main).
|
||||
# Fall back to the baked build SHA if available.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return {"upstream": baked, "local": baked, "ahead": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
ahead = 0
|
||||
|
||||
51
hermes_cli/build_info.py
Normal file
51
hermes_cli/build_info.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Baked-in build metadata for Hermes Agent.
|
||||
|
||||
Source installs report their git revision live via ``git rev-parse`` (see
|
||||
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
|
||||
the published Docker image because ``.dockerignore`` excludes ``.git``, so
|
||||
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
|
||||
|
||||
To make ``hermes dump`` and the startup banner identify the exact commit the
|
||||
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
|
||||
arg into ``<project_root>/.hermes_build_sha``. This module is the single
|
||||
read-side helper consumed by both callsites — keeping the lookup in one place
|
||||
so the file path and missing-file behaviour stay consistent.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Returns ``None`` when the file is absent. Source installs and dev images
|
||||
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
|
||||
resolution in the caller, so non-Docker installs are unaffected.
|
||||
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
|
||||
for support triage; nothing in the CLI is allowed to crash because of it.
|
||||
- Truncates to ``short`` characters (default 8) to match the format used by
|
||||
``git rev-parse --short=8`` throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
|
||||
|
||||
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
|
||||
the full 40-character commit hash on a single line.
|
||||
"""
|
||||
try:
|
||||
if not _BUILD_SHA_FILE.is_file():
|
||||
return None
|
||||
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not sha:
|
||||
return None
|
||||
return sha[:short] if short and short > 0 else sha
|
||||
@@ -345,6 +345,58 @@ def recommended_update_command() -> str:
|
||||
return recommended_update_command_for_method(method)
|
||||
|
||||
|
||||
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||||
# don't grow two slightly-different copies.
|
||||
#
|
||||
# Why this matters:
|
||||
# - The published image excludes ``.git`` (see .dockerignore), so the
|
||||
# git-based update path can never succeed inside the container.
|
||||
# - The pre-existing fallback message ("✗ Not a git repository. Please
|
||||
# reinstall: curl ... install.sh") is actively misleading inside Docker
|
||||
# — that script installs a *new* host-side Hermes, it doesn't update
|
||||
# the running container.
|
||||
# - The right action is ``docker pull`` + restart the container; this
|
||||
# helper spells that out, with notes on tag pinning and config
|
||||
# persistence so users don't get blindsided.
|
||||
_DOCKER_UPDATE_MESSAGE = """\
|
||||
✗ ``hermes update`` doesn't apply inside the Docker container.
|
||||
|
||||
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
|
||||
git checkout — the container has no working tree to pull into. Update by
|
||||
pulling a fresh image and restarting your container instead:
|
||||
|
||||
docker pull nousresearch/hermes-agent:latest
|
||||
# then restart whatever started the container, e.g.:
|
||||
docker compose up -d --force-recreate hermes-agent
|
||||
# or, for ad-hoc runs, exit the current container and `docker run` again
|
||||
|
||||
Verify the new version after restart:
|
||||
docker run --rm nousresearch/hermes-agent:latest --version
|
||||
|
||||
Notes:
|
||||
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
|
||||
won't move your container — pull the newer tag you actually want, or
|
||||
switch to ``:latest`` / ``:main`` for rolling updates. See available
|
||||
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
|
||||
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
|
||||
in the container, typically bind-mounted from the host) and persist
|
||||
across image upgrades — re-pulling doesn't lose any state.
|
||||
• Running a fork? Build your own image with this repo's ``Dockerfile``
|
||||
and replace the ``docker pull`` step with your build/push pipeline."""
|
||||
|
||||
|
||||
def format_docker_update_message() -> str:
|
||||
"""Return the user-facing message for ``hermes update`` inside Docker.
|
||||
|
||||
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check``
|
||||
(the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE``
|
||||
above for the full rationale.
|
||||
"""
|
||||
return _DOCKER_UPDATE_MESSAGE
|
||||
|
||||
|
||||
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||||
"""Build a user-facing error for managed installs."""
|
||||
managed_system = get_managed_system() or "a package manager"
|
||||
|
||||
@@ -20,7 +20,15 @@ from agent.skill_utils import is_excluded_skill_path
|
||||
|
||||
|
||||
def _get_git_commit(project_root: Path) -> str:
|
||||
"""Return short git commit hash, or '(unknown)'."""
|
||||
"""Return short git commit hash, or '(unknown)'.
|
||||
|
||||
Source installs and dev images resolve this live via ``git rev-parse``.
|
||||
The published Docker image excludes ``.git`` from the build context, so
|
||||
that lookup always fails — we fall back to the baked-in build SHA written
|
||||
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
||||
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
||||
The output format is identical regardless of source.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=8", "HEAD"],
|
||||
@@ -28,9 +36,23 @@ def _get_git_commit(project_root: Path) -> str:
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the build-time baked SHA (populated in published Docker
|
||||
# images, absent otherwise). Defers the import so the dump module
|
||||
# stays cheap on non-dump code paths.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return baked
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "(unknown)"
|
||||
|
||||
|
||||
|
||||
@@ -1021,7 +1021,7 @@ def _board_task_counts(slug: str) -> dict[str, int]:
|
||||
path = kb.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
with kb.connect(board=slug) as conn:
|
||||
with kb.connect_closing(board=slug) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
@@ -1264,7 +1264,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.heartbeat_worker(
|
||||
conn,
|
||||
args.task_id,
|
||||
@@ -1279,7 +1279,7 @@ def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_assignees(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
data = kb.known_assignees(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
@@ -1320,7 +1320,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
title=args.title,
|
||||
@@ -1369,7 +1369,7 @@ def _cmd_swarm(args: argparse.Namespace) -> int:
|
||||
if not workers:
|
||||
print("kanban swarm: at least one --worker is required", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
created = ks.create_swarm(
|
||||
conn,
|
||||
goal=args.goal,
|
||||
@@ -1395,7 +1395,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
|
||||
assignee = args.assignee
|
||||
if args.mine and not assignee:
|
||||
assignee = _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Cheap "mini-dispatch": recompute ready so list output reflects
|
||||
# dependencies that may have cleared since the last dispatcher tick.
|
||||
kb.recompute_ready(conn)
|
||||
@@ -1444,7 +1444,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, args.task_id)
|
||||
if not task:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
@@ -1610,7 +1610,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
|
||||
def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.assign_task(conn, args.task_id, profile)
|
||||
if not ok:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
@@ -1620,7 +1620,7 @@ def _cmd_assign(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reclaim_task(
|
||||
conn, args.task_id,
|
||||
reason=getattr(args, "reason", None),
|
||||
@@ -1637,7 +1637,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int:
|
||||
|
||||
def _cmd_reassign(args: argparse.Namespace) -> int:
|
||||
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.reassign_task(
|
||||
conn, args.task_id, profile,
|
||||
reclaim_first=bool(getattr(args, "reclaim", False)),
|
||||
@@ -1667,7 +1667,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
||||
|
||||
diag_config = kd.config_from_runtime_config(load_config())
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
# Either one-task mode or fleet mode.
|
||||
if getattr(args, "task", None):
|
||||
task = kb.get_task(conn, args.task)
|
||||
@@ -1790,14 +1790,14 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_link(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.link_tasks(conn, args.parent_id, args.child_id)
|
||||
print(f"Linked {args.parent_id} -> {args.child_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_unlink(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.unlink_tasks(conn, args.parent_id, args.child_id)
|
||||
if not ok:
|
||||
print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr)
|
||||
@@ -1807,7 +1807,7 @@ def _cmd_unlink(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_claim(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl)
|
||||
if task is None:
|
||||
# Report why
|
||||
@@ -1838,7 +1838,7 @@ def _cmd_comment(args: argparse.Namespace) -> int:
|
||||
suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]"
|
||||
body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix
|
||||
author = args.author or _profile_author()
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
kb.add_comment(conn, args.task_id, author, body)
|
||||
print(f"Comment added to {args.task_id}")
|
||||
return 0
|
||||
@@ -1885,7 +1885,7 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if not kb.complete_task(
|
||||
conn, tid,
|
||||
@@ -1912,7 +1912,7 @@ def _cmd_edit(args: argparse.Namespace) -> int:
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"kanban: --metadata: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if not kb.edit_completed_task_result(
|
||||
conn,
|
||||
args.task_id,
|
||||
@@ -1934,7 +1934,7 @@ def _cmd_block(args: argparse.Namespace) -> int:
|
||||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
|
||||
@@ -1956,7 +1956,7 @@ def _cmd_schedule(args: argparse.Namespace) -> int:
|
||||
author = _profile_author()
|
||||
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
|
||||
@@ -1979,7 +1979,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
if not kb.unblock_task(conn, tid):
|
||||
failed.append(tid)
|
||||
@@ -2003,7 +2003,7 @@ def _cmd_promote(args: argparse.Namespace) -> int:
|
||||
seen.add(tid)
|
||||
|
||||
results: list[dict[str, object]] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
for tid in ids:
|
||||
ok, err = kb.promote_task(
|
||||
conn,
|
||||
@@ -2050,7 +2050,7 @@ def _cmd_archive(args: argparse.Namespace) -> int:
|
||||
print("at least one task_id is required", file=sys.stderr)
|
||||
return 1
|
||||
failed: list[str] = []
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if purge_ids:
|
||||
for tid in purge_ids:
|
||||
if not kb.delete_archived_task(conn, tid):
|
||||
@@ -2073,7 +2073,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
||||
print(f"Tailing events for {args.task_id}. Ctrl-C to stop.")
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
events = kb.list_events(conn, args.task_id)
|
||||
for e in events:
|
||||
if e.id > last_id:
|
||||
@@ -2087,7 +2087,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
res = kb.dispatch_once(
|
||||
conn,
|
||||
dry_run=args.dry_run,
|
||||
@@ -2257,7 +2257,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
from the dispatcher's perspective, not stuck.
|
||||
"""
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
return kb.has_spawnable_ready(conn)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -2288,7 +2288,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
cursor = 0
|
||||
print("Watching kanban events. Ctrl-C to stop.", flush=True)
|
||||
# Seed cursor at the latest id so we don't replay history.
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
|
||||
).fetchone()
|
||||
@@ -2296,7 +2296,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
|
||||
try:
|
||||
while True:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
|
||||
" t.assignee, t.tenant "
|
||||
@@ -2329,7 +2329,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_stats(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
stats = kb.board_stats(conn)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
||||
@@ -2349,7 +2349,7 @@ def _cmd_stats(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
if kb.get_task(conn, args.task_id) is None:
|
||||
print(f"no such task: {args.task_id}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -2366,7 +2366,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_list(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
subs = kb.list_notify_subs(conn, args.task_id)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(subs, indent=2, ensure_ascii=False))
|
||||
@@ -2383,7 +2383,7 @@ def _cmd_notify_list(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.remove_notify_sub(
|
||||
conn, task_id=args.task_id,
|
||||
platform=args.platform, chat_id=args.chat_id,
|
||||
@@ -2417,7 +2417,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
runs = kb.list_runs(conn, args.task_id, **rsk)
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps([
|
||||
@@ -2456,7 +2456,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _cmd_context(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
text = kb.build_worker_context(conn, args.task_id)
|
||||
print(text)
|
||||
return 0
|
||||
@@ -2622,7 +2622,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
import shutil
|
||||
scratch_root = kb.workspaces_root()
|
||||
removed_ws = 0
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'"
|
||||
).fetchall()
|
||||
@@ -2645,7 +2645,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
|
||||
event_days = getattr(args, "event_retention_days", 30)
|
||||
log_days = getattr(args, "log_retention_days", 30)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
removed_events = kb.gc_events(
|
||||
conn, older_than_seconds=event_days * 24 * 3600,
|
||||
)
|
||||
|
||||
@@ -1236,6 +1236,41 @@ def connect(
|
||||
return conn
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def connect_closing(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
board: Optional[str] = None,
|
||||
):
|
||||
"""Open a kanban DB connection and guarantee it is closed on exit.
|
||||
|
||||
Use this instead of ``with kb.connect() as conn:`` — sqlite3's
|
||||
built-in connection context manager only commits/rollbacks the
|
||||
transaction; it does NOT close the file descriptor. In long-lived
|
||||
processes (gateway, dashboard) that route every kanban operation
|
||||
through ``connect()`` (e.g. ``run_slash`` dispatching ``/kanban …``
|
||||
commands, ``decompose_task_endpoint`` calling
|
||||
``kanban_decompose.decompose_task``), the unclosed connections
|
||||
accumulate as open FDs to ``kanban.db`` and ``kanban.db-wal``. After
|
||||
enough operations the process hits the kernel FD limit and dies
|
||||
with ``[Errno 24] Too many open files``.
|
||||
|
||||
See #33159 for the production incident.
|
||||
|
||||
The ``connect()`` function itself remains unchanged so callers that
|
||||
intentionally manage the connection lifetime (tests, long-lived
|
||||
callers) continue to work.
|
||||
"""
|
||||
conn = connect(db_path=db_path, board=board)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def init_db(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
|
||||
@@ -281,7 +281,7 @@ def decompose_task(
|
||||
configured, API error, malformed response, decomposer returned
|
||||
fanout=true with empty task list) — those surface via ``ok=False``.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return DecomposeOutcome(task_id, False, "unknown task id")
|
||||
@@ -370,7 +370,7 @@ def decompose_task(
|
||||
return DecomposeOutcome(
|
||||
task_id, False, "decomposer returned fanout=false with no title/body",
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -439,7 +439,7 @@ def decompose_task(
|
||||
})
|
||||
|
||||
try:
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -467,7 +467,7 @@ def decompose_task(
|
||||
|
||||
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
"""Return task ids currently in the triage column."""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
rows = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
||||
@@ -150,7 +150,7 @@ def specify_task(
|
||||
error, malformed response) — those surface via ``ok=False`` so the
|
||||
``--all`` sweep can continue past individual failures.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return SpecifyOutcome(task_id, False, "unknown task id")
|
||||
@@ -239,7 +239,7 @@ def specify_task(
|
||||
task_id, False, "LLM response missing title and body"
|
||||
)
|
||||
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
@@ -261,7 +261,7 @@ def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
|
||||
``tenant`` narrows the sweep; ``None`` returns every triage task.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
with kb.connect_closing() as conn:
|
||||
tasks = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
|
||||
@@ -8416,6 +8416,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
||||
"""
|
||||
from hermes_cli.config import detect_install_method
|
||||
method = detect_install_method(PROJECT_ROOT)
|
||||
if method == "docker":
|
||||
# Docker can't ``git fetch`` from within the container. Surface the
|
||||
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
||||
# path) uses — telling the user to "reinstall via curl" or that
|
||||
# ".git is missing" would point them at the wrong remediation.
|
||||
from hermes_cli.config import format_docker_update_message
|
||||
print(format_docker_update_message())
|
||||
sys.exit(1)
|
||||
if method == "pip":
|
||||
from hermes_cli.config import recommended_update_command
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
@@ -8716,12 +8724,27 @@ def cmd_update(args):
|
||||
runs the update, then restores stdio on the way out (even on
|
||||
``sys.exit`` or unhandled exceptions).
|
||||
"""
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_docker_update_message,
|
||||
is_managed,
|
||||
managed_error,
|
||||
)
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
# Docker users can't ``git pull`` — the image excludes ``.git`` from
|
||||
# the build context. Bail with a friendly explanation pointing at
|
||||
# ``docker pull`` BEFORE any of the apply-path / check-path branches
|
||||
# below get a chance to error out with misleading "Not a git
|
||||
# repository" text. See format_docker_update_message() for the full
|
||||
# rationale and tag-pinning / config-persistence notes.
|
||||
if detect_install_method(PROJECT_ROOT) == "docker":
|
||||
print(format_docker_update_message())
|
||||
sys.exit(1)
|
||||
|
||||
if getattr(args, "check", False):
|
||||
# --check honors --branch so the "any new commits?" answer matches
|
||||
# what a subsequent `hermes update --branch=<x>` would actually pull.
|
||||
|
||||
@@ -566,8 +566,11 @@ class S6ServiceManager:
|
||||
1. Sources HERMES_HOME (and any extra env) via with-contenv —
|
||||
so e.g. ``-e HERMES_HOME=/data/hermes`` is honored at run
|
||||
time, not Python-substituted at registration time (OQ8-C).
|
||||
2. Activates the bundled venv.
|
||||
3. Drops to the hermes user and exec's
|
||||
2. Resets ``HOME`` to ``/opt/data`` before the privilege drop
|
||||
so with-contenv's root HOME does not leak into the
|
||||
unprivileged gateway process.
|
||||
3. Activates the bundled venv.
|
||||
4. Drops to the hermes user and exec's
|
||||
``hermes -p <profile> gateway run`` (or just ``hermes
|
||||
gateway run`` for the default profile — see below).
|
||||
|
||||
@@ -597,6 +600,7 @@ class S6ServiceManager:
|
||||
"#!/command/with-contenv sh",
|
||||
"# shellcheck shell=sh",
|
||||
"set -e",
|
||||
"export HOME=/opt/data",
|
||||
"cd /opt/data",
|
||||
". /opt/hermes/.venv/bin/activate",
|
||||
]
|
||||
@@ -628,6 +632,38 @@ class S6ServiceManager:
|
||||
— so a container started with ``-e HERMES_HOME=/data/hermes``
|
||||
gets its logs under /data/hermes/logs/..., not the build-time
|
||||
default.
|
||||
|
||||
Output routing — the script is two action directives, applied
|
||||
per line, in order:
|
||||
|
||||
1. ``1`` (forward to stdout) — propagates the line up the
|
||||
s6-supervise pipeline to /init's stdout, which is the
|
||||
container's stdout, which is ``docker logs``. Without
|
||||
this, supervised stdout would be terminated inside
|
||||
s6-log and never reach the container's log stream;
|
||||
users would have to ``docker exec`` and ``tail`` the
|
||||
file just to see startup banners. (Python's ``logging``
|
||||
module defaults to stderr, which s6-supervise leaves
|
||||
unfiltered — so warnings/errors already reach docker
|
||||
logs. This change is specifically about the rich-console
|
||||
banner output and other plain stdout writes.)
|
||||
2. ``T <log_dir>`` — also write a timestamped copy to the
|
||||
rotated log directory (``current`` + archived ``@*.s``
|
||||
files). This is what ``hermes logs`` reads and what
|
||||
persists across container restarts via the volume mount.
|
||||
|
||||
``T`` is non-sticky: it only prefixes lines for the next
|
||||
action directive. We deliberately put ``T`` between ``1``
|
||||
and the log dir (not before ``1``) so:
|
||||
|
||||
* ``docker logs`` shows raw lines — Python's logging
|
||||
formatter has its own timestamps, and ``docker logs
|
||||
--timestamps`` adds a third layer when desired. No
|
||||
double-stamping in the most common reading path.
|
||||
* The persisted file gets s6-log's own ISO 8601 timestamp
|
||||
so even output that lacked a Python-logger timestamp
|
||||
(rich banners, third-party libs' raw prints) is
|
||||
correlatable in ``current``.
|
||||
"""
|
||||
import shlex
|
||||
prof = shlex.quote(profile)
|
||||
@@ -638,7 +674,7 @@ class S6ServiceManager:
|
||||
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
|
||||
f'mkdir -p "$log_dir"\n'
|
||||
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
|
||||
f'exec s6-setuidgid hermes s6-log n10 s1000000 T "$log_dir"\n'
|
||||
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
@@ -197,7 +197,9 @@ all = [
|
||||
# no native build path on Windows or modern macOS. With matrix in
|
||||
# [all], `uv sync --locked` on Windows tried to build it from sdist
|
||||
# and failed on `make`. Lazy-install routes that build to first use,
|
||||
# where the user is expected to have a toolchain available.
|
||||
# where the user is expected to have a toolchain available. The Docker
|
||||
# image ships `libolm-dev` so the lazy-install can build python-olm
|
||||
# from source in the container.
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[dev]",
|
||||
|
||||
@@ -67,3 +67,21 @@ def test_resume_rehydrates_previous_summary_from_handoff_message():
|
||||
assert "TURNS TO SUMMARIZE:" not in prompt
|
||||
assert prompt.count(old_summary) == 1
|
||||
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||
|
||||
|
||||
def test_handoff_in_protected_head_populates_previous_summary_before_update():
|
||||
"""A resumed protected-head handoff should restore iterative-summary state."""
|
||||
compressor = _compressor()
|
||||
old_summary = "PROTECTED-HEAD-SUMMARY durable facts from before restart"
|
||||
seen_turns = []
|
||||
|
||||
def fake_generate_summary(turns_to_summarize, focus_topic=None):
|
||||
seen_turns.extend(turns_to_summarize)
|
||||
return "new summary from resumed turns"
|
||||
|
||||
with patch.object(compressor, "_generate_summary", side_effect=fake_generate_summary):
|
||||
compressor.compress(_messages_with_handoff(old_summary))
|
||||
|
||||
assert compressor._previous_summary == old_summary
|
||||
assert seen_turns
|
||||
assert all(old_summary not in str(msg.get("content", "")) for msg in seen_turns)
|
||||
|
||||
290
tests/docker/test_docker_exec_privilege_drop.py
Normal file
290
tests/docker/test_docker_exec_privilege_drop.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Regression tests for the docker-exec privilege-drop shim.
|
||||
|
||||
The shim (docker/hermes-exec-shim.sh, installed at /opt/hermes/bin/hermes)
|
||||
exists to prevent the auth.json ownership-mismatch bug where
|
||||
`docker exec <c> hermes login` would write /opt/data/auth.json as
|
||||
root:root mode 0600, leaving the supervised gateway (UID 10000) unable
|
||||
to read its own credentials and returning "Provider authentication
|
||||
failed: Hermes is not logged into Nous Portal" on every message.
|
||||
|
||||
These tests verify:
|
||||
|
||||
1. ``docker exec <c> hermes …`` (defaulting to root) gets dropped to the
|
||||
hermes user before the real binary runs.
|
||||
2. ``docker exec --user hermes <c> hermes …`` (already non-root) short-
|
||||
circuits and doesn't try to drop again.
|
||||
3. Files written under $HERMES_HOME from a ``docker exec`` session land
|
||||
as hermes:hermes — the actual user-visible invariant.
|
||||
4. The HERMES_DOCKER_EXEC_AS_ROOT opt-out lets diagnostic sessions keep
|
||||
running as root deliberately.
|
||||
5. The main CMD path (``docker run <image> …``) is unaffected by the
|
||||
PATH-shim ordering — no recursion, no behavior change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# How long to give a `docker run -d` container before declaring it not ready.
|
||||
_RUN_READY_TIMEOUT_S = 20
|
||||
|
||||
|
||||
def _wait_for_init(container: str) -> None:
|
||||
"""Block until /init is up enough that `docker exec` is responsive."""
|
||||
deadline = time.time() + _RUN_READY_TIMEOUT_S
|
||||
while time.time() < deadline:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", container, "true"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
pytest.fail(f"container {container} not responsive to docker exec within {_RUN_READY_TIMEOUT_S}s")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sleep_container(built_image: str, container_name: str) -> Iterator[str]:
|
||||
"""Long-lived container running `sleep infinity` so we can docker exec into it."""
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_name],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
r = subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name, built_image,
|
||||
"sleep", "infinity"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"docker run failed: {r.stderr}"
|
||||
try:
|
||||
_wait_for_init(container_name)
|
||||
yield container_name
|
||||
finally:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_name],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_shim_drops_root_to_hermes_uid(sleep_container: str) -> None:
|
||||
"""docker exec defaults to root; the shim should drop to uid 10000.
|
||||
|
||||
We invoke `hermes` with a Python-style `-c` shim equivalent — there's no
|
||||
pure-hermes "print my uid" command, so we use the venv's python directly
|
||||
via the shim's PATH lookup: `python -c 'print(os.getuid())'` is resolved
|
||||
through the venv. But that bypasses the shim. Instead, we exploit the
|
||||
fact that the venv's `hermes` is a console_scripts entry — under the
|
||||
hood it's a tiny Python wrapper. We can't easily inject "print my uid"
|
||||
into it without forking subcommands. Simplest approach: have `hermes`
|
||||
do anything that writes to disk, then check the file's owner.
|
||||
|
||||
Use `hermes config set` which writes config.yaml under HERMES_HOME.
|
||||
The resulting file ownership tells us what UID the shim ended up at.
|
||||
"""
|
||||
# Wipe any prior state.
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
# Default docker exec (root) — should be dropped by the shim.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"hermes", "config", "set", "_test.shim_marker", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"config set failed: stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||
|
||||
# The written file must be owned by hermes, not root.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.returncode == 0, f"stat failed: {r.stderr}"
|
||||
assert r.stdout.strip() == "hermes:hermes", (
|
||||
f"config.yaml owned by {r.stdout.strip()!r}, expected hermes:hermes. "
|
||||
"The shim did not drop privileges before invoking hermes."
|
||||
)
|
||||
|
||||
|
||||
def test_shim_short_circuits_for_non_root_exec(sleep_container: str) -> None:
|
||||
"""docker exec --user hermes already runs as 10000; shim should be a no-op.
|
||||
|
||||
Verified indirectly: the command must still succeed end-to-end. If the
|
||||
shim incorrectly tried to drop privileges a second time (e.g. by
|
||||
invoking s6-setuidgid which requires root), it would fail with
|
||||
EPERM. A clean success proves the short-circuit fired.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "--user", "hermes", sleep_container,
|
||||
"hermes", "config", "set", "_test.shim_short_circuit", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"docker exec --user hermes failed: {r.stderr!r} stdout={r.stdout!r}. "
|
||||
"If the shim mis-handled the non-root path, this would fail with EPERM."
|
||||
)
|
||||
|
||||
# File still ends up hermes:hermes — orthogonally confirms uid.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "hermes:hermes"
|
||||
|
||||
|
||||
def test_shim_opt_out_keeps_root(sleep_container: str) -> None:
|
||||
"""HERMES_DOCKER_EXEC_AS_ROOT=1 should suppress the privilege drop.
|
||||
|
||||
Reserved for diagnostic sessions where the operator deliberately
|
||||
wants root semantics. Verified by writing a file and checking its
|
||||
owner.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec",
|
||||
"-e", "HERMES_DOCKER_EXEC_AS_ROOT=1",
|
||||
sleep_container,
|
||||
"hermes", "config", "set", "_test.opt_out", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"opt-out invocation failed: {r.stderr}"
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "root:root", (
|
||||
f"With HERMES_DOCKER_EXEC_AS_ROOT=1, expected root:root, "
|
||||
f"got {r.stdout.strip()!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("falsy_value", ["0", "false", "no", "", "garbage", "2"])
|
||||
def test_shim_opt_out_strict_truthiness(
|
||||
sleep_container: str, falsy_value: str,
|
||||
) -> None:
|
||||
"""Anything other than 1/true/yes (case-insensitive) does NOT opt out.
|
||||
|
||||
Strict truthiness so a typo (``HERMES_DOCKER_EXEC_AS_ROOT=0``) doesn't
|
||||
silently keep the user as root. Mirrors the policy used by
|
||||
``HERMES_GATEWAY_NO_SUPERVISE`` in #33583.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec",
|
||||
"-e", f"HERMES_DOCKER_EXEC_AS_ROOT={falsy_value}",
|
||||
sleep_container,
|
||||
"hermes", "config", "set", "_test.falsy", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"falsy value {falsy_value!r} caused failure: {r.stderr}"
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "hermes:hermes", (
|
||||
f"falsy opt-out value {falsy_value!r} unexpectedly suppressed the drop; "
|
||||
f"file owner is {r.stdout.strip()!r}, expected hermes:hermes"
|
||||
)
|
||||
|
||||
|
||||
def test_main_cmd_path_unaffected(built_image: str) -> None:
|
||||
"""The CMD path (docker run <image> <args>) must still work.
|
||||
|
||||
The shim sits at /opt/hermes/bin earliest on PATH; main-wrapper.sh
|
||||
invokes `s6-setuidgid hermes hermes <args>` which resolves `hermes`
|
||||
through PATH. With the shim in the way, this could regress if the
|
||||
shim recurses or interferes with TTY/exit-code propagation.
|
||||
|
||||
`chat --help` is cheap and exercises the full subcommand
|
||||
passthrough path. The duplicate of test_main_invocation's
|
||||
pre-existing test is intentional — that one would have passed
|
||||
pre-shim too; this one specifically guards against shim regressions
|
||||
in the CMD-as-main-program codepath.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "chat", "--help"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, f"CMD path broken by shim: stderr={r.stderr!r}"
|
||||
assert "Traceback" not in r.stderr
|
||||
|
||||
|
||||
def test_e2e_login_then_supervised_gateway_can_read_auth(
|
||||
sleep_container: str,
|
||||
) -> None:
|
||||
"""End-to-end regression for the original bug.
|
||||
|
||||
Pre-shim: ``docker exec <c> hermes login`` (root) wrote
|
||||
/opt/data/auth.json as root:root 0600. The supervised gateway (UID
|
||||
10000) couldn't read it, _load_auth_store swallowed PermissionError
|
||||
as a parse failure, and resolve_nous_runtime_credentials raised
|
||||
"Hermes is not logged into Nous Portal" on every message.
|
||||
|
||||
We can't do a real OAuth login in a unit test, but we can stand in
|
||||
for it by writing the same file shape via `hermes config set`-style
|
||||
writes — what matters is the *file ownership invariant* downstream
|
||||
of `_save_auth_store`. If the shim works, every file the
|
||||
`docker exec` path produces is hermes-readable.
|
||||
|
||||
Specifically: pretend the operator ran `hermes login` (writes
|
||||
auth.json) and verify (a) the file exists and (b) it's readable by
|
||||
the hermes UID. We use `hermes auth list` since that touches the
|
||||
auth store on the read side and would fail with the same
|
||||
'not logged in' shape if the file was unreadable to uid 10000.
|
||||
"""
|
||||
# Have the shim-protected `docker exec` write the auth store.
|
||||
# `hermes auth list` is read-only but still exercises _load_auth_store
|
||||
# under the shim's UID. We invoke `hermes config set` first to
|
||||
# provoke a write into HERMES_HOME so we have something concrete to
|
||||
# owner-check.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"hermes", "config", "set", "_test.e2e_marker", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"config set failed: {r.stderr}"
|
||||
|
||||
# The supervised UID (10000) must be able to read everything under
|
||||
# HERMES_HOME that docker exec just wrote.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "--user", "hermes", sleep_container,
|
||||
"find", "/opt/data", "-maxdepth", "2", "-type", "f",
|
||||
"!", "-readable", "-print"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
assert r.returncode == 0, f"find failed: {r.stderr}"
|
||||
unreadable = [ln for ln in r.stdout.splitlines() if ln.strip()]
|
||||
assert not unreadable, (
|
||||
"Files written by `docker exec` are unreadable to the hermes user "
|
||||
f"(supervised gateway UID): {unreadable}. The shim failed to drop "
|
||||
"privileges before the write."
|
||||
)
|
||||
104
tests/docker/test_dump_build_sha.py
Normal file
104
tests/docker/test_dump_build_sha.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Regression test: ``hermes dump`` reports a real git SHA inside the container.
|
||||
|
||||
Background: ``.dockerignore`` excludes ``.git``, so ``git rev-parse HEAD``
|
||||
fails inside the published image and ``hermes dump`` used to report
|
||||
``version: ... [(unknown)]``. The Dockerfile now writes the build-time
|
||||
``$HERMES_GIT_SHA`` build-arg to ``/opt/hermes/.hermes_build_sha`` and
|
||||
``hermes_cli/build_info.py`` reads it as a fallback.
|
||||
|
||||
CI (``.github/workflows/docker-publish.yml``) always sets the build-arg
|
||||
to ``${{ github.sha }}``. Local ``docker build`` (the ``built_image``
|
||||
fixture in ``tests/docker/conftest.py``) does NOT — so locally the file
|
||||
is absent and ``hermes dump`` correctly falls back to ``(unknown)``.
|
||||
|
||||
This test handles both cases:
|
||||
|
||||
* If ``/opt/hermes/.hermes_build_sha`` exists in the image, assert that
|
||||
``hermes dump`` surfaces its content as the version SHA (not
|
||||
``(unknown)``).
|
||||
* If the file is absent, assert the legacy behaviour (``(unknown)``)
|
||||
still holds — defensive guard against the helper accidentally
|
||||
reporting bogus data from somewhere else.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
_VERSION_LINE = re.compile(r"^version:\s+(?P<rest>.+)$", re.MULTILINE)
|
||||
_SHA_BRACKET = re.compile(r"\[(?P<sha>[^\]]+)\]\s*$")
|
||||
|
||||
|
||||
def _run_dump(image: str) -> str:
|
||||
"""Return the stdout of ``docker run <image> dump``.
|
||||
|
||||
Relies on Docker's anonymous VOLUME for ``/opt/data`` (declared by the
|
||||
Dockerfile) so the container's hermes user (UID 10000) can bootstrap
|
||||
its config. Anonymous volumes are auto-cleaned by ``--rm``, so unlike
|
||||
a host bind-mount we don't have to chown anything to UID 10000 (which
|
||||
would break cleanup on non-root hosts).
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", image, "dump"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"hermes dump exited {r.returncode}: "
|
||||
f"stderr={r.stderr[-1000:]!r}\nstdout={r.stdout[-1000:]!r}"
|
||||
)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _read_baked_sha_from_image(image: str) -> str | None:
|
||||
"""Return the ``/opt/hermes/.hermes_build_sha`` content, or None if absent."""
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm", "--entrypoint", "cat", image,
|
||||
"/opt/hermes/.hermes_build_sha",
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return r.stdout.strip() or None
|
||||
|
||||
|
||||
def test_dump_reports_baked_sha_when_present(built_image: str) -> None:
|
||||
"""When the image was built with ``HERMES_GIT_SHA``, dump must surface it.
|
||||
|
||||
Together with the smoke-test action (which exercises ``--help``), this
|
||||
closes the regression loop for the missing-sha bug: any future change
|
||||
that breaks the baked-file -> dump pipeline will fail CI here.
|
||||
"""
|
||||
baked = _read_baked_sha_from_image(built_image)
|
||||
stdout = _run_dump(built_image)
|
||||
|
||||
match = _VERSION_LINE.search(stdout)
|
||||
assert match, f"no `version:` line in dump output:\n{stdout[:2000]}"
|
||||
sha_match = _SHA_BRACKET.search(match.group("rest"))
|
||||
assert sha_match, (
|
||||
f"`version:` line missing [<sha>] bracket: {match.group('rest')!r}"
|
||||
)
|
||||
reported = sha_match.group("sha")
|
||||
|
||||
if baked is None:
|
||||
# Local-build path: no build-arg was passed. Verify the legacy
|
||||
# fallback ``(unknown)`` is intact — guards against the helper
|
||||
# ever inventing a SHA from thin air.
|
||||
assert reported == "(unknown)", (
|
||||
f"expected '(unknown)' when no SHA baked, got {reported!r}"
|
||||
)
|
||||
return
|
||||
|
||||
# CI path: build-arg was set, baked file exists. ``hermes dump``
|
||||
# truncates to 8 chars via ``git rev-parse --short=8`` semantics.
|
||||
assert reported != "(unknown)", (
|
||||
"baked SHA file present in image but dump still reported "
|
||||
f"'(unknown)' — the build-info fallback is broken. "
|
||||
f"Baked file content: {baked!r}"
|
||||
)
|
||||
assert reported == baked[:8], (
|
||||
f"dump reported {reported!r} but baked file contained {baked!r} "
|
||||
f"(expected first 8 chars: {baked[:8]!r})"
|
||||
)
|
||||
@@ -327,3 +327,69 @@ def test_dashboard_supervised_when_env_set(
|
||||
assert _svstat_wants_up(container_name, "dashboard"), (
|
||||
f"dashboard slot not up: {_svstat(container_name, 'dashboard')!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_supervised_gateway_stdout_reaches_docker_logs(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The supervised gateway's stdout — including the rich-console
|
||||
startup banner — must reach ``docker logs``, not just the rotated
|
||||
log file under ``${HERMES_HOME}/logs/gateways/<profile>/current``.
|
||||
|
||||
Without the ``1`` action directive in ``_render_log_run``, s6-log
|
||||
swallows the gateway's stdout into the file and ``docker logs``
|
||||
only sees stderr (Python ``logging`` defaults to stderr). That's
|
||||
a poor user experience: the iconic "Hermes Gateway Starting…"
|
||||
banner with the ⚕ symbol is the most visible "yes, your gateway
|
||||
started" signal, and forcing users to ``docker exec`` + ``tail``
|
||||
the log file just to see it is friction users don't expect.
|
||||
|
||||
With the ``1`` directive, s6-log forwards every line to its own
|
||||
stdout (which propagates up through the s6-supervise pipeline to
|
||||
/init's stdout = container stdout = ``docker logs``) AND also
|
||||
writes a timestamped copy to the rotated file. Best of both.
|
||||
|
||||
We assert by looking for the literal banner glyph (``⚕``) — a
|
||||
distinctive character that won't appear in stderr-routed
|
||||
Python-logging output, so its presence in ``docker logs`` proves
|
||||
the stdout-tee is working.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name, built_image,
|
||||
"gateway", "run"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
# Banner is printed during gateway startup — give it time to
|
||||
# initialize past the imports + config-load phase.
|
||||
time.sleep(8)
|
||||
|
||||
logs = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = logs.stdout + logs.stderr
|
||||
|
||||
# The banner ⚕ symbol is the load-bearing assertion — it's unique
|
||||
# to gateway startup stdout output and won't appear in stderr
|
||||
# (Python logging) or s6 boot messages.
|
||||
assert "⚕" in combined or "Hermes Gateway Starting" in combined, (
|
||||
"Supervised gateway's stdout banner did not reach docker logs. "
|
||||
"This means the `1` action directive in _render_log_run isn't "
|
||||
"forwarding stdout to /init. "
|
||||
f"docker logs (last 2000 chars):\n{combined[-2000:]}\n"
|
||||
f"file contents:\n{_sh(container_name, 'cat /opt/data/logs/gateways/default/current').stdout}"
|
||||
)
|
||||
|
||||
# Cross-check: the same banner must also be in the rotated log
|
||||
# file (we kept the file destination, just added stdout). The
|
||||
# file version has s6-log's ISO 8601 timestamp prefix; the
|
||||
# docker logs version is raw.
|
||||
file_contents = _sh(
|
||||
container_name, "cat /opt/data/logs/gateways/default/current",
|
||||
).stdout
|
||||
assert "⚕" in file_contents or "Hermes Gateway Starting" in file_contents, (
|
||||
"Banner also missing from rotated log file — the file "
|
||||
"destination may have been dropped by the new s6-log script. "
|
||||
f"File contents:\n{file_contents}"
|
||||
)
|
||||
|
||||
|
||||
@@ -61,3 +61,56 @@ def test_get_git_banner_state_reads_origin_and_head(tmp_path):
|
||||
state = banner.get_git_banner_state(repo_dir)
|
||||
|
||||
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
|
||||
|
||||
|
||||
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
|
||||
"""Docker image case: no .git checkout — baked build SHA fills the gap.
|
||||
|
||||
``_resolve_repo_dir`` returns None when neither the running code's
|
||||
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
|
||||
case inside the published container, where .git is dockerignored).
|
||||
The banner should still report the build SHA so support bug reports
|
||||
can identify the running commit.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
|
||||
state = banner.get_git_banner_state()
|
||||
|
||||
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
|
||||
|
||||
|
||||
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
|
||||
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
|
||||
|
||||
Banner correctly omits the upstream/local suffix in this case.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value=None):
|
||||
state = banner.get_git_banner_state()
|
||||
|
||||
assert state is None
|
||||
|
||||
|
||||
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
|
||||
"""Shallow clone without origin/main → still surface build SHA if baked.
|
||||
|
||||
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
|
||||
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
|
||||
happens AND a baked SHA exists, return the baked one instead of None.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
# All git invocations fail (returncode=1, empty stdout).
|
||||
failed = MagicMock(returncode=1, stdout="")
|
||||
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
|
||||
state = banner.get_git_banner_state(repo_dir)
|
||||
|
||||
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}
|
||||
|
||||
78
tests/hermes_cli/test_build_info.py
Normal file
78
tests/hermes_cli/test_build_info.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
|
||||
|
||||
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
|
||||
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
|
||||
helper: missing file, malformed file, truncation, and error tolerance.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
|
||||
"""Source installs: no file present → None, callers fall back to git."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
missing = tmp_path / ".hermes_build_sha" # never created
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_reads_baked_file(tmp_path):
|
||||
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_respects_short_argument(tmp_path):
|
||||
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
|
||||
sha_file.write_text(full_sha + "\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha(short=12) == "abcdef123456"
|
||||
assert build_info.get_build_sha(short=0) == full_sha
|
||||
assert build_info.get_build_sha(short=-1) == full_sha
|
||||
|
||||
|
||||
def test_get_build_sha_strips_whitespace(tmp_path):
|
||||
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" abcdef1234567890\n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
|
||||
"""A whitespace-only file is treated as absent."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" \n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_swallows_read_errors(tmp_path):
|
||||
"""Any IO exception from the read returns None — never raises."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
|
||||
patch.object(Path, "read_text", side_effect=OSError("boom")):
|
||||
assert build_info.get_build_sha() is None
|
||||
185
tests/hermes_cli/test_cmd_update_docker.py
Normal file
185
tests/hermes_cli/test_cmd_update_docker.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Tests for ``hermes update`` / ``--check`` inside the Docker container.
|
||||
|
||||
Background: ``.dockerignore`` excludes ``.git``, so the existing git-pull
|
||||
update path can never succeed inside the published image. Before this
|
||||
fix, ``hermes update`` would fall through to ``"✗ Not a git repository.
|
||||
Please reinstall: curl ... install.sh"`` — that script installs a *new*
|
||||
host-side Hermes, not an update to the running container, so the message
|
||||
was actively misleading.
|
||||
|
||||
These tests pin the new behaviour: when ``detect_install_method`` reports
|
||||
``"docker"`` (stamped by ``docker/stage2-hook.sh``), both the apply path
|
||||
(``cmd_update``) and the check path (``_cmd_update_check``) print the
|
||||
``docker pull`` guidance from ``format_docker_update_message`` and exit
|
||||
with status 1, without running ``git fetch`` / ``subprocess.run``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _cmd_update_check, cmd_update
|
||||
|
||||
|
||||
# ---------- cmd_update (apply path) ----------
|
||||
|
||||
|
||||
@patch("hermes_cli.config.is_managed", return_value=False)
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="docker")
|
||||
@patch("subprocess.run")
|
||||
def test_cmd_update_in_docker_prints_guidance_and_exits(
|
||||
mock_run, _mock_method, _mock_managed, capsys
|
||||
):
|
||||
"""``hermes update`` inside Docker → friendly message + exit 1, no git calls."""
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_update(SimpleNamespace(check=False))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
# Spot-check the key guidance — exhaustive wording is locked in by the
|
||||
# config-module test below to keep these CLI tests resilient to copy edits.
|
||||
assert "doesn't apply inside the Docker container" in out
|
||||
assert "docker pull nousresearch/hermes-agent:latest" in out
|
||||
|
||||
# No git invocations — the early-return must beat every git command.
|
||||
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
|
||||
assert git_calls == [], f"expected no git calls, got: {git_calls}"
|
||||
|
||||
|
||||
@patch("hermes_cli.config.is_managed", return_value=False)
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="docker")
|
||||
@patch("subprocess.run")
|
||||
def test_cmd_update_check_in_docker_prints_guidance_and_exits(
|
||||
mock_run, _mock_method, _mock_managed, capsys
|
||||
):
|
||||
"""``hermes update --check`` inside Docker → same message + exit 1, no fetch."""
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_update(SimpleNamespace(check=True, branch=None))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "doesn't apply inside the Docker container" in out
|
||||
assert "docker pull nousresearch/hermes-agent:latest" in out
|
||||
|
||||
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
|
||||
assert git_calls == [], f"expected no git calls, got: {git_calls}"
|
||||
|
||||
|
||||
@patch("hermes_cli.config.is_managed", return_value=False)
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="docker")
|
||||
@patch("subprocess.run")
|
||||
def test_cmd_update_in_docker_ignores_yes_and_force(
|
||||
mock_run, _mock_method, _mock_managed, capsys
|
||||
):
|
||||
"""``--yes`` / ``--force`` don't bypass the Docker bail-out.
|
||||
|
||||
The point of the bail-out is "git pull will never work here", so even
|
||||
a user trying to barge through with ``--yes --force`` should see the
|
||||
docker-pull guidance.
|
||||
"""
|
||||
with pytest.raises(SystemExit):
|
||||
cmd_update(SimpleNamespace(check=False, yes=True, force=True))
|
||||
|
||||
assert "docker pull" in capsys.readouterr().out
|
||||
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
|
||||
assert git_calls == []
|
||||
|
||||
|
||||
# ---------- _cmd_update_check (check path, direct entry) ----------
|
||||
|
||||
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="docker")
|
||||
@patch("subprocess.run")
|
||||
def test_cmd_update_check_direct_in_docker(mock_run, _mock_method, capsys):
|
||||
"""Calling ``_cmd_update_check`` directly (no apply path) also bails."""
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_cmd_update_check()
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert "docker pull" in capsys.readouterr().out
|
||||
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
|
||||
assert git_calls == []
|
||||
|
||||
|
||||
# ---------- Non-Docker installs unaffected ----------
|
||||
|
||||
|
||||
@patch("hermes_cli.config.is_managed", return_value=False)
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="git")
|
||||
@patch(
|
||||
"subprocess.run",
|
||||
return_value=SimpleNamespace(returncode=0, stdout="0\n", stderr=""),
|
||||
)
|
||||
def test_cmd_update_on_git_install_does_not_print_docker_message(
|
||||
_mock_run, _mock_method, _mock_managed, capsys
|
||||
):
|
||||
"""Source/git installs MUST NOT hit the Docker branch.
|
||||
|
||||
Regression guard: an over-eager detection refactor could accidentally
|
||||
route git users through the docker-pull message. We swallow
|
||||
SystemExit / unrelated errors from the rest of the update flow —
|
||||
those don't matter for this assertion; what matters is that the
|
||||
docker text is absent.
|
||||
|
||||
``subprocess.run`` is mocked because the git path will otherwise shell
|
||||
out to ``git fetch upstream`` / ``git fetch origin`` — on CI runners
|
||||
with no ``upstream`` remote configured this can hang past the 30s
|
||||
pytest-timeout depending on git's network behaviour. The stub
|
||||
returns a successful CompletedProcess-shaped object with ``"0\\n"``
|
||||
stdout, which both keeps the flow shell-free AND parses cleanly as
|
||||
the "0 commits behind" rev-list output the check path later parses
|
||||
via ``int(rev_result.stdout.strip())``.
|
||||
"""
|
||||
try:
|
||||
cmd_update(SimpleNamespace(check=True, branch=None))
|
||||
except (SystemExit, Exception):
|
||||
# Update flow may exit for unrelated reasons in a stubbed env —
|
||||
# that's fine; we only care about the banner not appearing.
|
||||
pass
|
||||
|
||||
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
|
||||
|
||||
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="pip")
|
||||
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
|
||||
def test_cmd_update_check_on_pip_install_still_uses_pypi(
|
||||
_mock_pypi, _mock_method, capsys
|
||||
):
|
||||
"""PyPI installs route to PyPI check, not the Docker bail-out."""
|
||||
_cmd_update_check()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Already up to date" in out
|
||||
assert "doesn't apply inside the Docker container" not in out
|
||||
|
||||
|
||||
# ---------- format_docker_update_message — content lock ----------
|
||||
|
||||
|
||||
def test_format_docker_update_message_contents():
|
||||
"""Lock in the high-value content of the Docker update message.
|
||||
|
||||
These are the bits a user actually needs to act on; if any of them
|
||||
disappear in a copy edit, the message has lost its value. Specific
|
||||
wording around them is free to evolve (we don't assert full text).
|
||||
"""
|
||||
from hermes_cli.config import format_docker_update_message
|
||||
|
||||
msg = format_docker_update_message()
|
||||
|
||||
# Primary command — the entire reason this message exists.
|
||||
assert "docker pull nousresearch/hermes-agent:latest" in msg
|
||||
|
||||
# The four key concepts the message must cover:
|
||||
assert "restart" in msg.lower(), "must explain that a restart is required"
|
||||
assert "--version" in msg, "must show how to verify the new version"
|
||||
assert ":latest" in msg, "must mention tag pinning caveat"
|
||||
assert "HERMES_HOME" in msg or "/opt/data" in msg, (
|
||||
"must address config persistence across upgrades"
|
||||
)
|
||||
|
||||
# Acknowledges that forks exist (build-your-own-image escape hatch).
|
||||
assert "fork" in msg.lower() or "Dockerfile" in msg
|
||||
118
tests/hermes_cli/test_dump_git_commit.py
Normal file
118
tests/hermes_cli/test_dump_git_commit.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
|
||||
|
||||
``hermes dump`` prints the running commit so support bug reports identify the
|
||||
exact version. Source installs resolve it live via ``git rev-parse``; the
|
||||
published Docker image excludes ``.git`` and falls back to the baked SHA
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
|
||||
|
||||
These tests cover both paths plus the failure modes (no git, no baked file).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_get_git_commit_uses_live_git_when_available(tmp_path):
|
||||
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
|
||||
# build_info should NOT be consulted when live git succeeds.
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
|
||||
patch("hermes_cli.build_info.get_build_sha") as mock_build:
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "deadbeef"
|
||||
mock_run.assert_called_once()
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
|
||||
"""Docker image case: live git returns non-zero → use baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "no-git-here"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "cafef00d"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
|
||||
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
empty = MagicMock(returncode=0, stdout="\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "abcdef12"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
|
||||
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "feedface"
|
||||
|
||||
|
||||
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
|
||||
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value=None):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "(unknown)"
|
||||
|
||||
|
||||
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
|
||||
"""Regression guard: live-git and baked-SHA outputs share the same shape.
|
||||
|
||||
Ben explicitly asked for identical output between Docker and source installs
|
||||
so support tooling that parses ``hermes dump`` doesn't have to special-case
|
||||
container builds. Both paths must return a bare 8-char SHA — no prefix,
|
||||
no suffix, no annotation.
|
||||
"""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
# Live-git path.
|
||||
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
|
||||
live = dump._get_git_commit(repo_dir)
|
||||
|
||||
# Baked-SHA path.
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
|
||||
baked = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert live == baked == "b2f477a3"
|
||||
# Same length, same charset — no decoration in either branch.
|
||||
assert len(live) == 8
|
||||
assert all(c in "0123456789abcdef" for c in live)
|
||||
@@ -3805,3 +3805,66 @@ def test_dispatch_once_still_reaps_via_extracted_fn(kanban_home):
|
||||
pids = kb.reap_worker_zombies()
|
||||
|
||||
assert pids == [99999]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect_closing(): context manager that actually closes the FD
|
||||
# Regression coverage for #33159 (kanban.db FD leak — gateway crashes after
|
||||
# ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but
|
||||
# does NOT close, so `with kb.connect() as conn:` leaks the FD in
|
||||
# long-lived processes (gateway run_slash, dashboard decompose handler).
|
||||
# `connect_closing()` is the leak-safe replacement.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_closing_closes_connection_on_exit(tmp_path):
|
||||
"""The new context manager MUST actually close the underlying FD."""
|
||||
db_path = tmp_path / "kanban.db"
|
||||
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
|
||||
with kb.connect_closing(db_path=db_path) as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
# After exit, the connection MUST be closed — subsequent execute
|
||||
# should raise ProgrammingError.
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
|
||||
def test_connect_closing_closes_on_exception(tmp_path):
|
||||
"""Connection closed even when the body raises."""
|
||||
db_path = tmp_path / "kanban.db"
|
||||
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
|
||||
captured = []
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with kb.connect_closing(db_path=db_path) as conn:
|
||||
captured.append(conn)
|
||||
raise RuntimeError("boom")
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
captured[0].execute("SELECT 1")
|
||||
|
||||
|
||||
def test_connect_closing_yields_usable_connection(tmp_path):
|
||||
"""Smoke test: schema is initialized and basic ops work."""
|
||||
db_path = tmp_path / "kanban.db"
|
||||
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
|
||||
with kb.connect_closing(db_path=db_path) as conn:
|
||||
tid = kb.create_task(conn, title="closing-cm test")
|
||||
task = kb.get_task(conn, tid)
|
||||
assert task is not None
|
||||
assert task.title == "closing-cm test"
|
||||
|
||||
|
||||
def test_bare_connect_does_not_close_on_context_exit(tmp_path):
|
||||
"""Document the leak that connect_closing exists to prevent.
|
||||
|
||||
sqlite3.Connection's __exit__ commits/rollbacks but doesn't close.
|
||||
This is the upstream behaviour we cannot change; the regression
|
||||
guard is to make sure connect_closing() does the right thing.
|
||||
"""
|
||||
db_path = tmp_path / "kanban.db"
|
||||
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
|
||||
with kb.connect(db_path=db_path) as conn:
|
||||
pass
|
||||
# Still usable after with-block exit (the leak).
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
conn.close() # explicit close to avoid leaking THIS test
|
||||
|
||||
@@ -536,6 +536,7 @@ def test_s6_register_creates_service_dir_and_triggers_scan(
|
||||
assert run_path.is_file()
|
||||
assert run_path.stat().st_mode & 0o111 # executable
|
||||
run_text = run_path.read_text()
|
||||
assert "export HOME=/opt/data" in run_text
|
||||
assert "hermes -p coder gateway run" in run_text
|
||||
assert "s6-setuidgid hermes" in run_text
|
||||
# Sentinel marking this as the supervised-child invocation. Without
|
||||
@@ -555,6 +556,16 @@ def test_s6_register_creates_service_dir_and_triggers_scan(
|
||||
assert "/opt/data/logs/gateways/coder" not in log_text, (
|
||||
"log_dir was hard-coded; must use ${HERMES_HOME} at run time"
|
||||
)
|
||||
# `1` action directive forwards lines to stdout BEFORE the file
|
||||
# destination so the supervised gateway's stdout (including the
|
||||
# rich-console banner and plain print() output) reaches docker
|
||||
# logs, not just the rotated file. See _render_log_run's docstring
|
||||
# for the full output-routing rationale.
|
||||
assert "s6-log 1 " in log_text, (
|
||||
"log/run must include the `1` action directive before the file "
|
||||
"destination so supervised stdout reaches docker logs. Saw: "
|
||||
f"{log_text!r}"
|
||||
)
|
||||
|
||||
# s6-svscanctl -a was invoked against the scandir
|
||||
assert any(
|
||||
@@ -576,6 +587,15 @@ def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> Non
|
||||
assert "export QUOTED='a'\"'\"'b'" in run_text
|
||||
|
||||
|
||||
def test_render_run_script_resets_home_before_exec() -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
|
||||
run_text = S6ServiceManager._render_run_script("coder", {})
|
||||
|
||||
assert "export HOME=/opt/data" in run_text
|
||||
assert "exec s6-setuidgid hermes hermes -p coder gateway run" in run_text
|
||||
|
||||
|
||||
def test_s6_register_rejects_invalid_profile_name(s6_scandir) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
|
||||
@@ -76,6 +76,78 @@ def test_background_review_shuts_down_memory_provider_before_close(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_background_review_summarizer_receives_captured_messages_after_close(monkeypatch):
|
||||
"""The action summarizer must see review messages even after close cleanup.
|
||||
|
||||
Regression for the bug where ``review_messages`` was snapshot AFTER
|
||||
``review_agent.close()``. close() is allowed to clean per-session state
|
||||
(including ``_session_messages``), so the summarizer would receive an
|
||||
empty list and the user-visible self-improvement summary would silently
|
||||
disappear. The fix snapshots ``_session_messages`` before teardown.
|
||||
"""
|
||||
import json
|
||||
import agent.background_review as bg_review
|
||||
|
||||
review_tool_message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_bg",
|
||||
"content": json.dumps(
|
||||
{"success": True, "message": "Entry added", "target": "memory"}
|
||||
),
|
||||
}
|
||||
captured: dict = {}
|
||||
events: list[str] = []
|
||||
|
||||
class FakeReviewAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self._session_messages = []
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
events.append("run_conversation")
|
||||
self._session_messages = [review_tool_message]
|
||||
|
||||
def shutdown_memory_provider(self):
|
||||
events.append("shutdown_memory_provider")
|
||||
|
||||
def close(self):
|
||||
events.append("close")
|
||||
# close() is allowed to clean _session_messages — the fix
|
||||
# must have snapshot them before this runs.
|
||||
self._session_messages = []
|
||||
|
||||
def fake_summarize(review_messages, prior_snapshot):
|
||||
events.append("summarize")
|
||||
captured["review_messages"] = list(review_messages)
|
||||
captured["prior_snapshot"] = list(prior_snapshot)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
|
||||
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
|
||||
monkeypatch.setattr(
|
||||
bg_review,
|
||||
"summarize_background_review_actions",
|
||||
fake_summarize,
|
||||
)
|
||||
|
||||
messages_snapshot = [{"role": "user", "content": "hi"}]
|
||||
agent = _bare_agent()
|
||||
|
||||
AIAgent._spawn_background_review(
|
||||
agent,
|
||||
messages_snapshot=messages_snapshot,
|
||||
review_memory=True,
|
||||
)
|
||||
|
||||
assert events == [
|
||||
"run_conversation",
|
||||
"shutdown_memory_provider",
|
||||
"close",
|
||||
"summarize",
|
||||
]
|
||||
assert captured["review_messages"] == [review_tool_message]
|
||||
assert captured["prior_snapshot"] == messages_snapshot
|
||||
|
||||
|
||||
def test_background_review_installs_auto_deny_approval_callback(monkeypatch):
|
||||
"""Regression guard for #15216.
|
||||
|
||||
|
||||
15
tests/test_docker_home_override_scripts.py
Normal file
15
tests/test_docker_home_override_scripts.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Regression tests for Docker HOME overrides under s6/with-contenv."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
|
||||
|
||||
|
||||
def test_dashboard_run_resets_home_before_dropping_privileges() -> None:
|
||||
text = DASHBOARD_RUN.read_text(encoding="utf-8")
|
||||
|
||||
assert "#!/command/with-contenv sh" in text
|
||||
assert "export HOME=/opt/data" in text
|
||||
assert "exec s6-setuidgid hermes hermes dashboard" in text
|
||||
@@ -317,6 +317,54 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
"upscale": False,
|
||||
},
|
||||
# Krea 2 — Krea's first foundation image model, day-0 partner launch on
|
||||
# fal (2026-05-27). Same model family as our direct ``plugins/image_gen/krea``
|
||||
# backend, exposed here for users who prefer to bill through their
|
||||
# existing FAL key / Nous Portal subscription rather than register
|
||||
# directly with Krea. Both variants share the same parameter schema —
|
||||
# only model id, price, and recommended use case differ.
|
||||
"fal-ai/krea/v2/medium/text-to-image": {
|
||||
"display": "Krea 2 Medium",
|
||||
"speed": "~15-25s",
|
||||
"strengths": "Illustration, anime, painting, expressive/artistic styles",
|
||||
"price": "$0.030 (text) / $0.035 (style refs)",
|
||||
"size_style": "aspect_ratio",
|
||||
# Krea natively accepts 1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16 —
|
||||
# we map our 3 abstract ratios to the closest match.
|
||||
"sizes": {
|
||||
"landscape": "16:9",
|
||||
"square": "1:1",
|
||||
"portrait": "9:16",
|
||||
},
|
||||
"defaults": {
|
||||
"creativity": "medium",
|
||||
},
|
||||
"supports": {
|
||||
"prompt", "aspect_ratio", "creativity", "seed",
|
||||
"image_style_references",
|
||||
},
|
||||
"upscale": False,
|
||||
},
|
||||
"fal-ai/krea/v2/large/text-to-image": {
|
||||
"display": "Krea 2 Large",
|
||||
"speed": "~25-60s",
|
||||
"strengths": "Photorealism, raw textured looks (motion blur, grain, film)",
|
||||
"price": "$0.060 (text) / $0.065 (style refs)",
|
||||
"size_style": "aspect_ratio",
|
||||
"sizes": {
|
||||
"landscape": "16:9",
|
||||
"square": "1:1",
|
||||
"portrait": "9:16",
|
||||
},
|
||||
"defaults": {
|
||||
"creativity": "medium",
|
||||
},
|
||||
"supports": {
|
||||
"prompt", "aspect_ratio", "creativity", "seed",
|
||||
"image_style_references",
|
||||
},
|
||||
"upscale": False,
|
||||
},
|
||||
}
|
||||
|
||||
# Default model is the fastest reasonable option. Kept cheap and sub-1s.
|
||||
|
||||
481
web/src/App.tsx
481
web/src/App.tsx
@@ -2,10 +2,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Puzzle,
|
||||
RotateCw,
|
||||
Settings,
|
||||
@@ -44,14 +48,15 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import { SidebarFooter } from "@/components/SidebarFooter";
|
||||
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
|
||||
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
|
||||
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
@@ -77,6 +82,7 @@ import type { PluginManifest } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
|
||||
import { api } from "@/lib/api";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
|
||||
function RootRedirect() {
|
||||
return <Navigate to="/sessions" replace />;
|
||||
@@ -306,6 +312,8 @@ function buildRoutes(
|
||||
return routes;
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
@@ -313,6 +321,27 @@ export default function App() {
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||
} catch { /* localStorage may be unavailable in private browsing */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const isMobile = useBelowBreakpoint(1024);
|
||||
const isDesktopCollapsed = collapsed && !isMobile;
|
||||
const tooltipWarmRef = useRef(0);
|
||||
const sidebarStatus = useSidebarStatus();
|
||||
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
|
||||
const normalizedPath = pathname.replace(/\/$/, "") || "/";
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
@@ -483,9 +512,11 @@ export default function App() {
|
||||
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
|
||||
"border-r border-current/20",
|
||||
"bg-background-base/95 backdrop-blur-sm",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"transition-[transform] duration-200 ease-out",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
|
||||
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
|
||||
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
|
||||
collapsed && "lg:w-14",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
@@ -495,11 +526,17 @@ export default function App() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
|
||||
"flex h-14 shrink-0 items-center gap-2",
|
||||
"border-b border-current/20",
|
||||
collapsed ? "lg:justify-center lg:px-0" : "px-4 justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-left" />
|
||||
|
||||
<Typography
|
||||
@@ -521,6 +558,22 @@ export default function App() {
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label={
|
||||
collapsed ? t.common.expand : t.common.collapse
|
||||
}
|
||||
className="hidden lg:flex text-text-secondary hover:text-midground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
@@ -531,9 +584,11 @@ export default function App() {
|
||||
{sidebarNav.coreItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -548,6 +603,7 @@ export default function App() {
|
||||
className={cn(
|
||||
"px-5 pt-2.5 pb-1",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
id="hermes-sidebar-plugin-nav-heading"
|
||||
>
|
||||
@@ -558,9 +614,11 @@ export default function App() {
|
||||
{sidebarNav.pluginItems.map((item) => (
|
||||
<SidebarNavLink
|
||||
closeMobile={closeMobile}
|
||||
collapsed={isDesktopCollapsed}
|
||||
item={item}
|
||||
key={item.path}
|
||||
t={t}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -568,24 +626,58 @@ export default function App() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<SidebarSystemActions onNavigate={closeMobile} />
|
||||
<SidebarSystemActions
|
||||
collapsed={isDesktopCollapsed}
|
||||
onNavigate={closeMobile}
|
||||
status={sidebarStatus}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"px-3 py-2",
|
||||
"border-t border-current/20",
|
||||
isDesktopCollapsed
|
||||
? "lg:flex-col lg:items-start lg:gap-3 lg:py-3"
|
||||
: "justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2",
|
||||
isDesktopCollapsed && "lg:flex-col lg:items-start",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher dropUp />
|
||||
<LanguageSwitcher dropUp />
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<ThemeSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
|
||||
<SidebarIconWithTooltip
|
||||
collapsed={isDesktopCollapsed}
|
||||
label={t.language.switchTo}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
>
|
||||
<LanguageSwitcher collapsed={isDesktopCollapsed} dropUp />
|
||||
</SidebarIconWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthWidget />
|
||||
<SidebarFooter />
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col",
|
||||
isDesktopCollapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<AuthWidget />
|
||||
<SidebarFooter status={sidebarStatus} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<PageHeaderProvider pluginTabs={pluginTabMeta}>
|
||||
@@ -660,22 +752,37 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
function SidebarNavLink({
|
||||
closeMobile,
|
||||
collapsed,
|
||||
item,
|
||||
tooltipWarmRef,
|
||||
t,
|
||||
}: SidebarNavLinkProps) {
|
||||
const { path, label, labelKey, icon: Icon } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
aria-label={collapsed ? navLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3",
|
||||
"group/nav relative flex items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
@@ -692,11 +799,19 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{navLabel}</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}
|
||||
>
|
||||
{navLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/nav:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
@@ -709,11 +824,20 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
function SidebarSystemActions({
|
||||
collapsed,
|
||||
onNavigate,
|
||||
status,
|
||||
tooltipWarmRef,
|
||||
}: SidebarSystemActionsProps) {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
@@ -755,75 +879,248 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
|
||||
className={cn(
|
||||
"px-5 pt-0.5 pb-0.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
{t.app.system}
|
||||
</span>
|
||||
|
||||
<SidebarStatusStrip />
|
||||
<div className={cn(collapsed && "lg:hidden")}>
|
||||
<SidebarStatusStrip status={status} />
|
||||
</div>
|
||||
|
||||
<GatewayDot collapsed={collapsed} status={status} tooltipWarmRef={tooltipWarmRef} />
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
|
||||
const isPending = pendingAction === action;
|
||||
const isActionRunning =
|
||||
activeAction === action && isRunning && !isPending;
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const disabled = isBusy && !busy;
|
||||
|
||||
return (
|
||||
<li key={action}>
|
||||
<ListItem
|
||||
onClick={() => handleClick(action)}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
active={busy}
|
||||
className={cn(
|
||||
"gap-3 px-5 py-1.5 whitespace-nowrap",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"transition-colors",
|
||||
busy
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
"disabled:text-text-disabled",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<SystemActionButton
|
||||
key={item.action}
|
||||
collapsed={collapsed}
|
||||
disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))}
|
||||
tooltipWarmRef={tooltipWarmRef}
|
||||
isPending={pendingAction === item.action}
|
||||
isRunning={activeAction === item.action && isRunning && pendingAction !== item.action}
|
||||
item={item}
|
||||
onClick={() => handleClick(item.action)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemActionButton({
|
||||
collapsed,
|
||||
disabled,
|
||||
isPending,
|
||||
isRunning: isActionRunning,
|
||||
item,
|
||||
onClick,
|
||||
tooltipWarmRef,
|
||||
}: SystemActionButtonProps) {
|
||||
const { icon: Icon, label, runningLabel, spin } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
aria-label={collapsed ? displayLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/action relative flex w-full items-center gap-3",
|
||||
"px-5 py-2.5",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
busy
|
||||
? "text-midground"
|
||||
: "text-text-secondary hover:text-midground",
|
||||
"disabled:text-text-disabled disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : isActionRunning && spin ? (
|
||||
<Spinner className="shrink-0 text-[0.875rem]" />
|
||||
) : (
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActionRunning && !spin && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className={cn(
|
||||
"truncate transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-0" : "lg:opacity-100",
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/action:opacity-5"
|
||||
/>
|
||||
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarIconWithTooltip({
|
||||
children,
|
||||
collapsed,
|
||||
label,
|
||||
tooltipWarmRef,
|
||||
}: SidebarIconWithTooltipProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-fit",
|
||||
collapsed && "group/icon",
|
||||
)}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
{collapsed && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 inset-x-[-0.375rem] bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/icon:opacity-5 hidden lg:block"
|
||||
/>
|
||||
)}
|
||||
|
||||
{collapsed && hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
||||
const { t } = useI18n();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const toneToColor: Record<string, string> = {
|
||||
"text-success": "bg-success",
|
||||
"text-warning": "bg-warning",
|
||||
"text-destructive": "bg-destructive",
|
||||
"text-muted-foreground": "bg-muted-foreground",
|
||||
};
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
|
||||
if (!status) {
|
||||
color = "bg-midground/20";
|
||||
label = t.status.gateway;
|
||||
} else {
|
||||
const gw = gatewayLine(status, t);
|
||||
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
||||
label = `${t.status.gateway} ${gw.label}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
|
||||
)}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
||||
/>
|
||||
|
||||
{hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const sidebar = document.getElementById("app-sidebar");
|
||||
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
||||
|
||||
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
return () => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
};
|
||||
}, [warmRef]);
|
||||
|
||||
return createPortal(
|
||||
<span
|
||||
className={cn(
|
||||
"fixed z-[100] pointer-events-none",
|
||||
"px-2 py-1",
|
||||
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
|
||||
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top + rect.height / 2,
|
||||
left: sidebarRight + 8,
|
||||
transform: "translateY(-50%)",
|
||||
opacity: isWarm ? 1 : undefined,
|
||||
animation: isWarm ? "none" : "sidebar-tooltip-in 120ms ease-out",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipWarmRef = React.RefObject<number>;
|
||||
|
||||
interface GatewayDotProps {
|
||||
collapsed: boolean;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -831,10 +1128,42 @@ interface NavItem {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarIconWithTooltipProps {
|
||||
children: ReactNode;
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarNavLinkProps {
|
||||
closeMobile: () => void;
|
||||
collapsed: boolean;
|
||||
item: NavItem;
|
||||
t: Translations;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarSystemActionsProps {
|
||||
collapsed: boolean;
|
||||
onNavigate: () => void;
|
||||
status: StatusResponse | null;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SidebarTooltipProps {
|
||||
anchor: HTMLElement;
|
||||
label: string;
|
||||
warmRef?: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionButtonProps {
|
||||
collapsed: boolean;
|
||||
disabled: boolean;
|
||||
isPending: boolean;
|
||||
isRunning: boolean;
|
||||
item: SystemActionItem;
|
||||
onClick: () => void;
|
||||
tooltipWarmRef: TooltipWarmRef;
|
||||
}
|
||||
|
||||
interface SystemActionItem {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
@@ -25,10 +27,11 @@ import { cn } from "@/lib/utils";
|
||||
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
|
||||
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
|
||||
*/
|
||||
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
@@ -41,15 +44,14 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
@@ -69,7 +71,10 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
aria-label={t.language.switchTo}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
|
||||
className={cn(
|
||||
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
collapsed && "hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Typography
|
||||
@@ -99,23 +104,33 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<LanguageSwitcherOptions
|
||||
allLocales={allLocales}
|
||||
locale={locale}
|
||||
setLocale={setLocale}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,10 +149,12 @@ function LanguageSwitcherOptions({
|
||||
return (
|
||||
<button
|
||||
aria-selected={selected}
|
||||
className={
|
||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
|
||||
"font-mondwest text-display text-xs tracking-[0.08em]",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
selected ? "font-semibold text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setLocale(code);
|
||||
@@ -148,7 +165,7 @@ function LanguageSwitcherOptions({
|
||||
>
|
||||
<span className="truncate">{meta.name}</span>
|
||||
|
||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps {
|
||||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Typography } from "@/components/NouiTypography";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export function SidebarFooter() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarFooter({ status }: SidebarFooterProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
@@ -37,3 +36,7 @@ export function SidebarFooter() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarFooterProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { StatusResponse } from "@/lib/api";
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
|
||||
export function SidebarStatusStrip() {
|
||||
const status = useSidebarStatus();
|
||||
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (status === null) {
|
||||
@@ -50,7 +48,7 @@ export function SidebarStatusStrip() {
|
||||
);
|
||||
}
|
||||
|
||||
function gatewayLine(
|
||||
export function gatewayLine(
|
||||
status: StatusResponse,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): { label: string; tone: string } {
|
||||
@@ -68,3 +66,7 @@ function gatewayLine(
|
||||
? { label: g.running, tone: "text-success" }
|
||||
: { label: g.off, tone: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
interface SidebarStatusStripProps {
|
||||
status: StatusResponse | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
@@ -23,11 +24,12 @@ import { cn } from "@/lib/utils";
|
||||
* bottom sheet portaled to `document.body` so the picker is not clipped by
|
||||
* the sidebar (same idea as a responsive Drawer).
|
||||
*/
|
||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const narrowViewport = useBelowBreakpoint(640);
|
||||
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||
|
||||
@@ -45,12 +47,10 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
useEffect(() => {
|
||||
if (!open || useMobileSheet) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (wrapperRef.current?.contains(target)) return;
|
||||
if (dropdownRef.current?.contains(target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
@@ -64,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<Button
|
||||
ghost
|
||||
size={collapsed ? "icon" : undefined}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
|
||||
title={t.theme?.switchTheme ?? "Switch theme"}
|
||||
className={cn(
|
||||
collapsed
|
||||
? "text-text-secondary hover:text-foreground hover:bg-transparent"
|
||||
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
|
||||
)}
|
||||
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
|
||||
aria-label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
@@ -74,12 +79,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{!collapsed && (
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-display tracking-wide text-xs"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -101,34 +108,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||
</BottomPickSheet>
|
||||
)}
|
||||
|
||||
{open && !useMobileSheet && (
|
||||
<div
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
{open && !useMobileSheet && (() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
aria-label={sheetTitle}
|
||||
className={cn(
|
||||
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
|
||||
)}
|
||||
role="listbox"
|
||||
style={
|
||||
dropUp && rect
|
||||
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
|
||||
>
|
||||
{sheetTitle}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ThemeSwitcherOptions
|
||||
availableThemes={availableThemes}
|
||||
close={close}
|
||||
setTheme={setTheme}
|
||||
themeName={themeName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return dropUp ? createPortal(dropdown, document.body) : dropdown;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,5 +238,6 @@ interface ThemeSwitcherOptionsProps {
|
||||
}
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
collapsed?: boolean;
|
||||
dropUp?: boolean;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export const af: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessies",
|
||||
history: "Geskiedenis",
|
||||
overview: "Oorsig",
|
||||
searchPlaceholder: "Soek boodskap-inhoud...",
|
||||
noSessions: "Nog geen sessies nie",
|
||||
@@ -422,7 +423,7 @@ export const af: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Skakel oor na Engels",
|
||||
switchTo: "Verander taal",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const de: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sitzungen",
|
||||
history: "Verlauf",
|
||||
overview: "Übersicht",
|
||||
searchPlaceholder: "Nachrichteninhalt suchen...",
|
||||
noSessions: "Noch keine Sitzungen",
|
||||
@@ -422,7 +423,7 @@ export const de: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Zu Englisch wechseln",
|
||||
switchTo: "Sprache wechseln",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const en: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "History",
|
||||
overview: "Overview",
|
||||
searchPlaceholder: "Search message content...",
|
||||
noSessions: "No sessions yet",
|
||||
@@ -422,7 +423,7 @@ export const en: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Switch to Chinese",
|
||||
switchTo: "Switch language",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const es: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sesiones",
|
||||
history: "Historial",
|
||||
overview: "Resumen",
|
||||
searchPlaceholder: "Buscar contenido de mensajes...",
|
||||
noSessions: "Aún no hay sesiones",
|
||||
@@ -422,7 +423,7 @@ export const es: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Cambiar a inglés",
|
||||
switchTo: "Cambiar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const fr: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessions",
|
||||
history: "Historique",
|
||||
overview: "Aperçu",
|
||||
searchPlaceholder: "Rechercher dans les messages...",
|
||||
noSessions: "Aucune session pour l'instant",
|
||||
@@ -422,7 +423,7 @@ export const fr: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passer à l'anglais",
|
||||
switchTo: "Changer de langue",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const ga: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Seisiúin",
|
||||
history: "Stair",
|
||||
overview: "Forbhreathnú",
|
||||
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
|
||||
noSessions: "Gan seisiúin go fóill",
|
||||
@@ -422,7 +423,7 @@ export const ga: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Athraigh go Béarla",
|
||||
switchTo: "Athraigh teanga",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const hu: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Munkamenetek",
|
||||
history: "Előzmények",
|
||||
overview: "Áttekintés",
|
||||
searchPlaceholder: "Keresés üzenettartalomban...",
|
||||
noSessions: "Még nincsenek munkamenetek",
|
||||
@@ -422,7 +423,7 @@ export const hu: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Váltás angolra",
|
||||
switchTo: "Nyelv váltása",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const it: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessioni",
|
||||
history: "Cronologia",
|
||||
overview: "Panoramica",
|
||||
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
|
||||
noSessions: "Nessuna sessione",
|
||||
@@ -422,7 +423,7 @@ export const it: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Passa all'inglese",
|
||||
switchTo: "Cambia lingua",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const ja: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "セッション",
|
||||
history: "履歴",
|
||||
overview: "概要",
|
||||
searchPlaceholder: "メッセージ内容を検索...",
|
||||
noSessions: "まだセッションがありません",
|
||||
@@ -422,7 +423,7 @@ export const ja: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "英語に切り替え",
|
||||
switchTo: "言語を切り替え",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const ko: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "세션",
|
||||
history: "기록",
|
||||
overview: "개요",
|
||||
searchPlaceholder: "메시지 내용 검색...",
|
||||
noSessions: "아직 세션이 없습니다",
|
||||
@@ -422,7 +423,7 @@ export const ko: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "영어로 전환",
|
||||
switchTo: "언어 변경",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const pt: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Sessões",
|
||||
history: "Histórico",
|
||||
overview: "Visão geral",
|
||||
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
|
||||
noSessions: "Ainda não há sessões",
|
||||
@@ -422,7 +423,7 @@ export const pt: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Mudar para inglês",
|
||||
switchTo: "Mudar idioma",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const ru: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Сессии",
|
||||
history: "История",
|
||||
overview: "Обзор",
|
||||
searchPlaceholder: "Поиск по содержимому сообщений...",
|
||||
noSessions: "Сессий пока нет",
|
||||
@@ -422,7 +423,7 @@ export const ru: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Переключиться на английский",
|
||||
switchTo: "Сменить язык",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const tr: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Oturumlar",
|
||||
history: "Geçmiş",
|
||||
overview: "Genel bakış",
|
||||
searchPlaceholder: "Mesaj içeriğinde ara...",
|
||||
noSessions: "Henüz oturum yok",
|
||||
@@ -422,7 +423,7 @@ export const tr: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "İngilizce'ye geç",
|
||||
switchTo: "Dil değiştir",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface Translations {
|
||||
// ── Sessions page ──
|
||||
sessions: {
|
||||
title: string;
|
||||
history: string;
|
||||
overview: string;
|
||||
searchPlaceholder: string;
|
||||
noSessions: string;
|
||||
|
||||
@@ -127,6 +127,7 @@ export const uk: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "Сесії",
|
||||
history: "Історія",
|
||||
overview: "Огляд",
|
||||
searchPlaceholder: "Пошук у вмісті повідомлень...",
|
||||
noSessions: "Поки немає сесій",
|
||||
@@ -422,7 +423,7 @@ export const uk: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "Перемкнути на англійську",
|
||||
switchTo: "Змінити мову",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const zhHant: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "工作階段",
|
||||
history: "歷史",
|
||||
overview: "總覽",
|
||||
searchPlaceholder: "搜尋訊息內容...",
|
||||
noSessions: "尚無工作階段",
|
||||
@@ -422,7 +423,7 @@ export const zhHant: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切換為英文",
|
||||
switchTo: "切換語言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -126,6 +126,7 @@ export const zh: Translations = {
|
||||
|
||||
sessions: {
|
||||
title: "会话",
|
||||
history: "历史",
|
||||
overview: "概览",
|
||||
searchPlaceholder: "搜索消息内容...",
|
||||
noSessions: "暂无会话",
|
||||
@@ -417,7 +418,7 @@ export const zh: Translations = {
|
||||
},
|
||||
|
||||
language: {
|
||||
switchTo: "切换到英文",
|
||||
switchTo: "切换语言",
|
||||
},
|
||||
|
||||
theme: {
|
||||
|
||||
@@ -124,6 +124,18 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||
dashboard sizes. Keep in sync. */
|
||||
small { font-size: 1.0625rem; }
|
||||
@@ -170,6 +182,12 @@ code { font-size: 0.875rem; }
|
||||
}
|
||||
|
||||
|
||||
/* Collapsed sidebar tooltip entrance — skipped when moving between items. */
|
||||
@keyframes sidebar-tooltip-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-4px); }
|
||||
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
|
||||
/* Toast animations used by `components/Toast.tsx`. */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
|
||||
@@ -778,7 +778,7 @@ export default function SessionsPage() {
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "overview", label: t.sessions.overview },
|
||||
{ value: "list", label: t.sessions.title },
|
||||
{ value: "list", label: t.sessions.history },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,15 @@ You'll see a one-line breadcrumb in `docker logs` confirming the upgrade. To opt
|
||||
This behavior applies to the s6-based image only. Earlier (tini-based) images still run `gateway run` as the foreground main process.
|
||||
:::
|
||||
|
||||
:::note Where gateway logs go
|
||||
Inside the s6 image, the supervised gateway's output is tee'd to two destinations:
|
||||
|
||||
- **`docker logs <container>`** — every line in real time (raw, no extra prefix). This is the same stream you'd get from a foreground gateway, so existing `docker logs --follow` / `--timestamps` / log-shipper integrations work unchanged.
|
||||
- **`${HERMES_HOME}/logs/gateways/<profile>/current`** (mapped to `~/.hermes/logs/gateways/<profile>/current` on the host via the volume mount) — rotated, with an ISO 8601 timestamp prepended per line. Rotation is 10 archives × 1 MB each, so it can't fill the disk. This is what `hermes logs` reads and what survives container restarts.
|
||||
|
||||
The per-profile reconciler keeps a separate audit log at `${HERMES_HOME}/logs/container-boot.log` — one line per profile per container boot, recording whether each gateway was restored to its prior state.
|
||||
:::
|
||||
|
||||
Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and an `API_SERVER_KEY` (minimum 8 characters — generate one with `openssl rand -hex 32`). Example:
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user