@bitseek/hermes-webui 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hermes Web UI -- Self-update checker.
|
|
3
|
+
|
|
4
|
+
Checks if the webui and hermes-agent git repos are behind their latest
|
|
5
|
+
release tags. Results are cached server-side (30-min TTL) so git fetch runs
|
|
6
|
+
at most twice per hour regardless of client count.
|
|
7
|
+
|
|
8
|
+
Skips repos that are not git checkouts (e.g. Docker baked images where
|
|
9
|
+
.git does not exist).
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Lazy -- may be None if agent not found
|
|
30
|
+
try:
|
|
31
|
+
from api.config import _AGENT_DIR
|
|
32
|
+
except ImportError:
|
|
33
|
+
_AGENT_DIR = None
|
|
34
|
+
|
|
35
|
+
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
|
|
36
|
+
_SUMMARY_CACHE_MAX = 16
|
|
37
|
+
_summary_cache: OrderedDict = OrderedDict()
|
|
38
|
+
_cache_lock = threading.Lock()
|
|
39
|
+
_check_in_progress = False
|
|
40
|
+
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
|
41
|
+
CACHE_TTL = 1800 # 30 minutes
|
|
42
|
+
_GIT_DIAGNOSTIC_MAX_CHARS = 300
|
|
43
|
+
_CREDENTIAL_IN_URL_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@\s'\"]+)@")
|
|
44
|
+
_GITHUB_TOKEN_RE = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")
|
|
45
|
+
_QUERY_SECRET_RE = re.compile(r"([?&](?:access_token|token|password|auth|key)=)[^&\s'\"]+", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sanitize_git_diagnostic(output: str, *, limit: int = _GIT_DIAGNOSTIC_MAX_CHARS) -> str:
|
|
49
|
+
"""Return a user-facing git diagnostic with credentials removed.
|
|
50
|
+
|
|
51
|
+
Git can echo remote URLs in failure output. Keep the actionable error text,
|
|
52
|
+
but strip URL userinfo, common GitHub token shapes, and secret-looking query
|
|
53
|
+
parameter values before any message reaches the update-check API/UI.
|
|
54
|
+
"""
|
|
55
|
+
if not output:
|
|
56
|
+
return ""
|
|
57
|
+
sanitized = _CREDENTIAL_IN_URL_RE.sub(r"\1<redacted>@", str(output))
|
|
58
|
+
sanitized = _GITHUB_TOKEN_RE.sub("<redacted>", sanitized)
|
|
59
|
+
sanitized = _QUERY_SECRET_RE.sub(r"\1<redacted>", sanitized)
|
|
60
|
+
sanitized = sanitized.strip()
|
|
61
|
+
if len(sanitized) > limit:
|
|
62
|
+
sanitized = sanitized[:limit].rstrip() + "…"
|
|
63
|
+
return sanitized
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _restart_blocker_snapshot() -> dict:
|
|
67
|
+
"""Return active chat work that should block a self-restart."""
|
|
68
|
+
with STREAMS_LOCK:
|
|
69
|
+
stream_ids = [str(k) for k in STREAMS.keys()]
|
|
70
|
+
run_ids: list[str] = []
|
|
71
|
+
try:
|
|
72
|
+
from api import config as _config
|
|
73
|
+
active_runs = getattr(_config, 'ACTIVE_RUNS', {})
|
|
74
|
+
active_runs_lock = getattr(_config, 'ACTIVE_RUNS_LOCK', None)
|
|
75
|
+
if active_runs_lock is not None:
|
|
76
|
+
with active_runs_lock:
|
|
77
|
+
run_ids = [str(k) for k in active_runs.keys()]
|
|
78
|
+
else:
|
|
79
|
+
run_ids = [str(k) for k in active_runs.keys()]
|
|
80
|
+
except Exception:
|
|
81
|
+
run_ids = []
|
|
82
|
+
return {
|
|
83
|
+
'active_streams': len(stream_ids),
|
|
84
|
+
'active_runs': len(run_ids),
|
|
85
|
+
'blocking_stream_ids': stream_ids[:10],
|
|
86
|
+
'blocking_run_ids': run_ids[:10],
|
|
87
|
+
'restart_blocked': bool(stream_ids or run_ids),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _active_stream_count() -> int:
|
|
92
|
+
"""Return the current in-memory chat stream count.
|
|
93
|
+
|
|
94
|
+
Kept for compatibility with older tests/helpers; restart safety should use
|
|
95
|
+
``_restart_blocker_snapshot()`` so detached worker runs also block updates.
|
|
96
|
+
"""
|
|
97
|
+
return int(_restart_blocker_snapshot().get('active_streams') or 0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _restart_blocked_response(target: str, blocker_snapshot: dict | int) -> dict:
|
|
101
|
+
if isinstance(blocker_snapshot, int):
|
|
102
|
+
blocker_snapshot = {
|
|
103
|
+
'active_streams': blocker_snapshot,
|
|
104
|
+
'active_runs': 0,
|
|
105
|
+
'blocking_stream_ids': [],
|
|
106
|
+
'blocking_run_ids': [],
|
|
107
|
+
'restart_blocked': bool(blocker_snapshot),
|
|
108
|
+
}
|
|
109
|
+
active_streams = int(blocker_snapshot.get('active_streams') or 0)
|
|
110
|
+
active_runs = int(blocker_snapshot.get('active_runs') or 0)
|
|
111
|
+
parts = []
|
|
112
|
+
if active_streams:
|
|
113
|
+
parts.append(f"{active_streams} active chat stream{'s' if active_streams != 1 else ''}")
|
|
114
|
+
if active_runs:
|
|
115
|
+
parts.append(f"{active_runs} active agent run{'s' if active_runs != 1 else ''}")
|
|
116
|
+
detail = ' and '.join(parts) or 'active chat work'
|
|
117
|
+
return {
|
|
118
|
+
'ok': False,
|
|
119
|
+
'message': (
|
|
120
|
+
f'Cannot update {target} while {detail} is running. '
|
|
121
|
+
'Wait for the response to finish, then retry the update.'
|
|
122
|
+
),
|
|
123
|
+
'target': target,
|
|
124
|
+
'restart_blocked': True,
|
|
125
|
+
'active_streams': active_streams,
|
|
126
|
+
'active_runs': active_runs,
|
|
127
|
+
'blocking_stream_ids': blocker_snapshot.get('blocking_stream_ids') or [],
|
|
128
|
+
'blocking_run_ids': blocker_snapshot.get('blocking_run_ids') or [],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _wait_until_restart_safe(poll_seconds: float = 2.0, max_wait_seconds: float = 300.0) -> dict:
|
|
133
|
+
"""Wait for active work to finish before self-reexec.
|
|
134
|
+
|
|
135
|
+
Bounded by ``max_wait_seconds`` so a long-running (or stuck/orphaned) agent
|
|
136
|
+
run can't soft-jam the self-update indefinitely. If the deadline is reached
|
|
137
|
+
while work is still in flight, the snapshot is returned with
|
|
138
|
+
``wait_timed_out=True`` so the caller can proceed with the re-exec anyway
|
|
139
|
+
(preserving the pre-#3105 "execv preempts in-flight work" fallback) rather
|
|
140
|
+
than holding ``_apply_lock`` for the run's full lifetime.
|
|
141
|
+
"""
|
|
142
|
+
snapshot = _restart_blocker_snapshot()
|
|
143
|
+
deadline = time.monotonic() + max(0.0, max_wait_seconds)
|
|
144
|
+
while snapshot.get('restart_blocked'):
|
|
145
|
+
if time.monotonic() >= deadline:
|
|
146
|
+
logger.warning(
|
|
147
|
+
"restart-safety wait exceeded %.0fs with work still in flight (%s); "
|
|
148
|
+
"proceeding with re-exec anyway",
|
|
149
|
+
max_wait_seconds, snapshot,
|
|
150
|
+
)
|
|
151
|
+
snapshot = dict(snapshot)
|
|
152
|
+
snapshot['wait_timed_out'] = True
|
|
153
|
+
return snapshot
|
|
154
|
+
time.sleep(max(0.1, poll_seconds))
|
|
155
|
+
snapshot = _restart_blocker_snapshot()
|
|
156
|
+
return snapshot
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _run_git(args, cwd, timeout=10):
|
|
160
|
+
"""Run a git command and return (useful output, ok).
|
|
161
|
+
|
|
162
|
+
On failure, returns stderr (or stdout as fallback) so callers can
|
|
163
|
+
surface actionable git error messages instead of empty strings.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
r = subprocess.run(
|
|
167
|
+
['git'] + args, cwd=str(cwd), capture_output=True,
|
|
168
|
+
text=True, timeout=timeout,
|
|
169
|
+
encoding='utf-8', errors='replace',
|
|
170
|
+
)
|
|
171
|
+
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
|
|
172
|
+
# output that fails to decode used to leave r.stdout = None and crash
|
|
173
|
+
# the whole import with AttributeError. Guard against None defensively.
|
|
174
|
+
stdout = (r.stdout or '').strip()
|
|
175
|
+
stderr = (r.stderr or '').strip()
|
|
176
|
+
if r.returncode == 0:
|
|
177
|
+
return stdout, True
|
|
178
|
+
return stderr or stdout or f"git exited with status {r.returncode}", False
|
|
179
|
+
except subprocess.TimeoutExpired as exc:
|
|
180
|
+
detail = (getattr(exc, 'stderr', None) or getattr(exc, 'stdout', None) or '').strip()
|
|
181
|
+
return detail or f"git {' '.join(args)} timed out after {timeout}s", False
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
return 'git executable not found', False
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
return f'git failed to start: {exc}', False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _dirty_suffix(path: Path, timeout=1) -> str:
|
|
189
|
+
"""Return a best-effort ``-dirty`` suffix without blocking version display."""
|
|
190
|
+
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
|
191
|
+
if ok:
|
|
192
|
+
return ""
|
|
193
|
+
# diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree
|
|
194
|
+
# (not an error). _run_git() substitutes a synthetic "git exited with
|
|
195
|
+
# status N" diagnostic when both streams are empty, which makes the naive
|
|
196
|
+
# `if not out` guard always false on dirty trees — silently dropping the
|
|
197
|
+
# suffix and defeating dev-build cache busting (static/foo.js?v=… stays
|
|
198
|
+
# identical to the last-committed version). Treat the synthetic shape as
|
|
199
|
+
# the dirty signal; real errors (timeouts, missing git) carry a different
|
|
200
|
+
# diagnostic and correctly suppress the suffix.
|
|
201
|
+
if not out or out.startswith('git exited with status '):
|
|
202
|
+
diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout)
|
|
203
|
+
if diff_ok and diff:
|
|
204
|
+
digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8]
|
|
205
|
+
return f"-dirty-{digest}"
|
|
206
|
+
return "-dirty"
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
|
|
211
|
+
"""Return a fast git version string for a checkout, if available."""
|
|
212
|
+
out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout)
|
|
213
|
+
if not (ok and out):
|
|
214
|
+
return None
|
|
215
|
+
return out + _dirty_suffix(path, timeout=dirty_timeout)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _detect_webui_version() -> str:
|
|
219
|
+
"""Detect the running WebUI version from git or a baked-in fallback file.
|
|
220
|
+
|
|
221
|
+
Resolution order:
|
|
222
|
+
1. ``git describe --tags --always --dirty`` — works in any git checkout.
|
|
223
|
+
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
|
|
224
|
+
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
|
|
225
|
+
or a bare SHA when no tags exist (shallow clones, fresh forks).
|
|
226
|
+
2. ``api/_version.py`` — a fallback written by the Docker / CI release
|
|
227
|
+
workflow when ``.git`` is not present in the image. Expected to define
|
|
228
|
+
``__version__ = 'vX.Y.Z'``.
|
|
229
|
+
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
|
|
230
|
+
"""
|
|
231
|
+
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
|
|
232
|
+
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
|
|
233
|
+
out = _describe_git_version(REPO_ROOT)
|
|
234
|
+
if out:
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
# Docker / baked-image fallback: api/_version.py written by CI at build time.
|
|
238
|
+
# Parse with regex rather than exec() — the file holds exactly one assignment
|
|
239
|
+
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
|
|
240
|
+
version_file = REPO_ROOT / 'api' / '_version.py'
|
|
241
|
+
if version_file.exists():
|
|
242
|
+
try:
|
|
243
|
+
import re as _re
|
|
244
|
+
m = _re.search(
|
|
245
|
+
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
|
|
246
|
+
version_file.read_text(encoding='utf-8'),
|
|
247
|
+
)
|
|
248
|
+
if m:
|
|
249
|
+
return m.group(1)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
return 'unknown'
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_agent_source_version(agent_dir: Path) -> str | None:
|
|
257
|
+
"""Read Hermes Agent's package version from a copied source tree."""
|
|
258
|
+
init_file = agent_dir / 'hermes_cli' / '__init__.py'
|
|
259
|
+
try:
|
|
260
|
+
text = init_file.read_text(encoding='utf-8')
|
|
261
|
+
except (OSError, UnicodeDecodeError):
|
|
262
|
+
return None
|
|
263
|
+
m = re.search(r"""__version__\s*=\s*['"]([^'"]+)['"]""", text)
|
|
264
|
+
if m and m.group(1).strip():
|
|
265
|
+
return m.group(1).strip()
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _gateway_health_base_url() -> str:
|
|
270
|
+
"""Return the configured/default Hermes Agent gateway base URL."""
|
|
271
|
+
raw = (
|
|
272
|
+
os.environ.get('GATEWAY_HEALTH_URL')
|
|
273
|
+
or os.environ.get('HERMES_GATEWAY_HEALTH_URL')
|
|
274
|
+
or 'http://hermes-agent:8642'
|
|
275
|
+
).strip()
|
|
276
|
+
if raw.endswith('/health/detailed'):
|
|
277
|
+
raw = raw[: -len('/health/detailed')]
|
|
278
|
+
elif raw.endswith('/health'):
|
|
279
|
+
raw = raw[: -len('/health')]
|
|
280
|
+
return raw.rstrip('/')
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _version_from_gateway_health_payload(payload: object) -> str | None:
|
|
284
|
+
"""Extract a version string from a Hermes Agent gateway health payload."""
|
|
285
|
+
if not isinstance(payload, dict):
|
|
286
|
+
return None
|
|
287
|
+
for key in ('version', 'agent_version', 'hermes_version'):
|
|
288
|
+
value = payload.get(key)
|
|
289
|
+
if isinstance(value, str) and value.strip():
|
|
290
|
+
return value.strip()
|
|
291
|
+
nested = payload.get('agent')
|
|
292
|
+
if isinstance(nested, dict):
|
|
293
|
+
value = nested.get('version')
|
|
294
|
+
if isinstance(value, str) and value.strip():
|
|
295
|
+
return value.strip()
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _detect_agent_version_from_gateway_health(timeout: float = 0.75) -> str | None:
|
|
300
|
+
"""Best-effort cross-container gateway API fallback for Agent version."""
|
|
301
|
+
base = _gateway_health_base_url()
|
|
302
|
+
if not base:
|
|
303
|
+
return None
|
|
304
|
+
parsed = urlparse(base)
|
|
305
|
+
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
|
306
|
+
return None
|
|
307
|
+
for path in ('/health', '/health/detailed'):
|
|
308
|
+
try:
|
|
309
|
+
with urllib.request.urlopen(f'{base}{path}', timeout=timeout) as resp:
|
|
310
|
+
payload = json.loads(resp.read().decode('utf-8'))
|
|
311
|
+
except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
|
|
312
|
+
continue
|
|
313
|
+
version = _version_from_gateway_health_payload(payload)
|
|
314
|
+
if version:
|
|
315
|
+
return version
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _detect_agent_version() -> str:
|
|
320
|
+
"""Detect the running Hermes Agent version for UI display."""
|
|
321
|
+
agent_dir = Path(_AGENT_DIR) if _AGENT_DIR is not None else None
|
|
322
|
+
|
|
323
|
+
if agent_dir is not None:
|
|
324
|
+
version_file = agent_dir / "VERSION"
|
|
325
|
+
try:
|
|
326
|
+
if version_file.exists():
|
|
327
|
+
text = version_file.read_text(encoding='utf-8').strip()
|
|
328
|
+
if text:
|
|
329
|
+
return text
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# Fallback: infer from git describe when the checkout exists but no VERSION
|
|
334
|
+
# file is available (common in source checkouts and developer environments).
|
|
335
|
+
if agent_dir.exists():
|
|
336
|
+
# Symmetric with _detect_webui_version() above — `--dirty` flags a
|
|
337
|
+
# locally-modified checkout so operators can see when their agent has
|
|
338
|
+
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
|
|
339
|
+
out = _describe_git_version(agent_dir)
|
|
340
|
+
if out:
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
# Docker two-container deployments often mount a copied agent source
|
|
344
|
+
# tree without .git metadata or a VERSION file. The package version
|
|
345
|
+
# still lives in hermes_cli/__init__.py, so prefer that before giving
|
|
346
|
+
# up or relying on a live gateway probe.
|
|
347
|
+
source_version = _read_agent_source_version(agent_dir)
|
|
348
|
+
if source_version:
|
|
349
|
+
return source_version
|
|
350
|
+
|
|
351
|
+
gateway_version = _detect_agent_version_from_gateway_health()
|
|
352
|
+
if gateway_version:
|
|
353
|
+
return gateway_version
|
|
354
|
+
|
|
355
|
+
return 'not detected'
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Resolved once at import time — tags cannot change without a process restart.
|
|
359
|
+
WEBUI_VERSION: str = _detect_webui_version()
|
|
360
|
+
AGENT_VERSION: str = _detect_agent_version()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _normalize_remote_url(remote_url):
|
|
364
|
+
"""Return the browser-facing repository URL for update compare links.
|
|
365
|
+
|
|
366
|
+
Git remotes may be HTTPS or SSH and may include a literal ``.git`` suffix.
|
|
367
|
+
Strip only that literal suffix — never use ``str.rstrip('.git')`` because it
|
|
368
|
+
treats the argument as a character set and can truncate ``hermes-webui`` to
|
|
369
|
+
``hermes-webu``.
|
|
370
|
+
"""
|
|
371
|
+
if not remote_url:
|
|
372
|
+
return remote_url
|
|
373
|
+
remote_url = remote_url.strip()
|
|
374
|
+
if remote_url.startswith('git@'):
|
|
375
|
+
remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1)
|
|
376
|
+
remote_url = remote_url.rstrip('/')
|
|
377
|
+
if remote_url.endswith('.git'):
|
|
378
|
+
remote_url = remote_url[:-4]
|
|
379
|
+
return remote_url.rstrip('/')
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _build_compare_url(repo_url, current_sha, latest_sha):
|
|
383
|
+
"""Return a safe browser compare URL, or None when any piece is missing."""
|
|
384
|
+
if not (repo_url and current_sha and latest_sha):
|
|
385
|
+
return None
|
|
386
|
+
parsed = urlparse(repo_url)
|
|
387
|
+
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
|
388
|
+
return None
|
|
389
|
+
return f"{repo_url}/compare/{current_sha}...{latest_sha}"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _split_remote_ref(ref):
|
|
393
|
+
"""Split 'origin/branch-name' into ('origin', 'branch-name').
|
|
394
|
+
|
|
395
|
+
Returns (None, ref) if ref contains no slash.
|
|
396
|
+
"""
|
|
397
|
+
if '/' not in ref:
|
|
398
|
+
return None, ref
|
|
399
|
+
remote, branch = ref.split('/', 1)
|
|
400
|
+
return remote, branch
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _detect_default_branch(path):
|
|
404
|
+
"""Detect the remote default branch (master or main)."""
|
|
405
|
+
out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path)
|
|
406
|
+
if ok and out:
|
|
407
|
+
# refs/remotes/origin/master -> master
|
|
408
|
+
return out.split('/')[-1]
|
|
409
|
+
# Fallback: try master, then main
|
|
410
|
+
for branch in ('master', 'main'):
|
|
411
|
+
_, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path)
|
|
412
|
+
if ok:
|
|
413
|
+
return branch
|
|
414
|
+
return 'master'
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _release_tags(path):
|
|
418
|
+
"""Return release tags newest-first, using the repo's version-sort order."""
|
|
419
|
+
out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path)
|
|
420
|
+
if not (ok and out):
|
|
421
|
+
return []
|
|
422
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _current_release_tag(path):
|
|
426
|
+
"""Return the latest release tag reachable from HEAD, if one exists."""
|
|
427
|
+
out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path)
|
|
428
|
+
return out if ok and out else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _release_gap(tags, current, latest):
|
|
432
|
+
"""Count release tags between current and latest in a newest-first list."""
|
|
433
|
+
if not latest or current == latest:
|
|
434
|
+
return 0
|
|
435
|
+
if current in tags:
|
|
436
|
+
return tags.index(current)
|
|
437
|
+
return 1
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _head_is_past_latest_tag(path, current_tag):
|
|
441
|
+
"""Return True when HEAD has moved past the latest reachable release tag.
|
|
442
|
+
|
|
443
|
+
`git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``)
|
|
444
|
+
when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix
|
|
445
|
+
when HEAD has moved 608 commits past it. Used by both the update check and
|
|
446
|
+
the update apply path so they agree on which ref to advance to — see #2653
|
|
447
|
+
(check side) and #2846 (apply side).
|
|
448
|
+
"""
|
|
449
|
+
if not current_tag:
|
|
450
|
+
return False
|
|
451
|
+
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
|
|
452
|
+
return bool(ok and full_desc and full_desc != current_tag)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _head_contains_ref(path, ref):
|
|
456
|
+
"""Return True when ``ref`` is an ancestor of HEAD.
|
|
457
|
+
|
|
458
|
+
Release-channel checks are tag-name based, but users tracking ``main`` can
|
|
459
|
+
be on a commit that already contains the newest published tag. In that case
|
|
460
|
+
a positive tag gap is not an available update; applying the tag would move
|
|
461
|
+
backwards or fail fast-forward. Use the commit graph to detect that state.
|
|
462
|
+
"""
|
|
463
|
+
if not ref:
|
|
464
|
+
return False
|
|
465
|
+
_, ok = _run_git(['merge-base', '--is-ancestor', ref, 'HEAD'], path)
|
|
466
|
+
return bool(ok)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _can_fast_forward_to(path, ref):
|
|
470
|
+
"""Return True when ``ref`` is a descendant of HEAD (``git pull --ff-only`` can reach it)."""
|
|
471
|
+
if not ref:
|
|
472
|
+
return False
|
|
473
|
+
_, ok = _run_git(['merge-base', '--is-ancestor', 'HEAD', ref], path)
|
|
474
|
+
return bool(ok)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _select_apply_compare_ref(path):
|
|
478
|
+
"""Return the same remote ref family that the update check reports.
|
|
479
|
+
|
|
480
|
+
The update banner prefers published release tags when they exist. Applying
|
|
481
|
+
an update must therefore advance to the latest release tag too; otherwise a
|
|
482
|
+
checkout on a local/fork tracking branch can report release updates, pull a
|
|
483
|
+
different branch that is already current, restart, and still remain behind.
|
|
484
|
+
|
|
485
|
+
When HEAD is past the latest tag (the agent repo's day-to-day state between
|
|
486
|
+
tagged releases), the check side falls through to the branch comparison via
|
|
487
|
+
`_check_repo_release` returning None. The apply side must mirror that
|
|
488
|
+
decision — otherwise we run `git pull --ff-only <latest-tag>` against a
|
|
489
|
+
checkout that's already past the tag, no-op, restart, and the banner
|
|
490
|
+
re-appears with the same N commits available. See #2846.
|
|
491
|
+
"""
|
|
492
|
+
tags = _release_tags(path)
|
|
493
|
+
if tags:
|
|
494
|
+
latest_tag = tags[0]
|
|
495
|
+
current_tag = _current_release_tag(path)
|
|
496
|
+
behind = _release_gap(tags, current_tag, latest_tag)
|
|
497
|
+
# Mirror the check side exactly: fall through to the branch comparison
|
|
498
|
+
# whenever the checkout has already moved past the release tag that the
|
|
499
|
+
# banner would otherwise advertise. The common case is behind == 0 and
|
|
500
|
+
# HEAD is past its nearest tag, but main-tracking checkouts can also
|
|
501
|
+
# have behind > 0 after fetching a newer tag that HEAD already contains
|
|
502
|
+
# (#3140). In both cases applying the tag would no-op, move backwards,
|
|
503
|
+
# or fail fast-forward; branch comparison is the truthful update path.
|
|
504
|
+
if (
|
|
505
|
+
behind == 0 and _head_is_past_latest_tag(path, current_tag)
|
|
506
|
+
) or (
|
|
507
|
+
behind > 0 and _head_contains_ref(path, latest_tag)
|
|
508
|
+
) or (
|
|
509
|
+
behind > 0 and not _can_fast_forward_to(path, latest_tag)
|
|
510
|
+
):
|
|
511
|
+
pass
|
|
512
|
+
else:
|
|
513
|
+
return latest_tag
|
|
514
|
+
|
|
515
|
+
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
|
516
|
+
if ok and upstream:
|
|
517
|
+
return upstream
|
|
518
|
+
|
|
519
|
+
branch = _detect_default_branch(path)
|
|
520
|
+
return f'origin/{branch}'
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _check_repo_release(path, name):
|
|
524
|
+
"""Check if a git repo is behind its latest published release tag."""
|
|
525
|
+
tags = _release_tags(path)
|
|
526
|
+
if not tags:
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
latest_tag = tags[0]
|
|
530
|
+
current_tag = _current_release_tag(path)
|
|
531
|
+
behind = _release_gap(tags, current_tag, latest_tag)
|
|
532
|
+
|
|
533
|
+
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
|
|
534
|
+
# keeps committing to master between tagged releases), the release check
|
|
535
|
+
# would report "Up to date" even though hundreds of commits are missing.
|
|
536
|
+
# Fall through to _check_repo_branch so the real commit count is reported
|
|
537
|
+
# instead. The same predicate is used by _select_apply_compare_ref so the
|
|
538
|
+
# check and apply sides cannot drift again. See #2653 (check), #2846 (apply).
|
|
539
|
+
if behind == 0 and _head_is_past_latest_tag(path, current_tag):
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
# Users tracking main can already contain the newest fetched release tag
|
|
543
|
+
# while their nearest reachable tag is older. A positive tag gap then means
|
|
544
|
+
# only "there is a newer tag name", not "HEAD is behind that tag" (#3140).
|
|
545
|
+
# Fall through to the branch check so the banner compares against the
|
|
546
|
+
# configured upstream instead of advertising a tag that cannot fast-forward.
|
|
547
|
+
if behind > 0 and _head_contains_ref(path, latest_tag):
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
# Patch releases can land on a side branch while day-to-day installs track
|
|
551
|
+
# main past an older tag. A positive tag-name gap then advertises an update
|
|
552
|
+
# that `git pull --ff-only <latest-tag>` cannot reach.
|
|
553
|
+
if behind > 0 and not _can_fast_forward_to(path, latest_tag):
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
|
557
|
+
remote_url = _normalize_remote_url(remote_url)
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
'name': name,
|
|
561
|
+
'behind': behind,
|
|
562
|
+
# GitHub compare URLs accept tag names, and tag-to-tag links are the
|
|
563
|
+
# clearest "what changed in this release?" view for operators.
|
|
564
|
+
'current_sha': current_tag,
|
|
565
|
+
'latest_sha': latest_tag,
|
|
566
|
+
'branch': latest_tag,
|
|
567
|
+
'repo_url': remote_url,
|
|
568
|
+
'release_based': True,
|
|
569
|
+
'current_version': current_tag,
|
|
570
|
+
'latest_version': latest_tag,
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _check_repo_branch(path, name, *, fetch=True):
|
|
575
|
+
"""Fallback: check if a git repo is behind its upstream branch."""
|
|
576
|
+
|
|
577
|
+
# Fetch latest from origin (network call, cached by TTL)
|
|
578
|
+
if fetch:
|
|
579
|
+
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
|
580
|
+
if not fetch_ok:
|
|
581
|
+
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
|
|
582
|
+
|
|
583
|
+
# Use the current branch's upstream tracking branch, not the repo default.
|
|
584
|
+
# This avoids false "N updates behind" alerts when the user is on a feature
|
|
585
|
+
# branch and master/main has moved forward with unrelated commits.
|
|
586
|
+
# If no upstream is set (brand-new local branch), fall back to the default branch.
|
|
587
|
+
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
|
588
|
+
if ok and upstream:
|
|
589
|
+
# upstream is like "origin/feat/foo" — use it directly in rev-list
|
|
590
|
+
compare_ref = upstream
|
|
591
|
+
else:
|
|
592
|
+
branch = _detect_default_branch(path)
|
|
593
|
+
compare_ref = f'origin/{branch}'
|
|
594
|
+
|
|
595
|
+
# Count commits behind
|
|
596
|
+
out, ok = _run_git(['rev-list', '--count', f'HEAD..{compare_ref}'], path)
|
|
597
|
+
behind = int(out) if ok and out.isdigit() else 0
|
|
598
|
+
|
|
599
|
+
# Get short SHAs for display.
|
|
600
|
+
#
|
|
601
|
+
# latest_sha = upstream tip (compare_ref). Always exists on github.com
|
|
602
|
+
# because it is literally the commit `git fetch` just pulled.
|
|
603
|
+
#
|
|
604
|
+
# current_sha is trickier. The intuitive choice — local HEAD — breaks
|
|
605
|
+
# the "What's new?" compare URL whenever HEAD is not a public commit:
|
|
606
|
+
# unpushed work, dirty stage branches, forks, in-flight rebases, or
|
|
607
|
+
# release-time merge commits whose SHA only lives in the maintainer's
|
|
608
|
+
# checkout. We saw exactly this in #1579: a banner reporting "17 updates"
|
|
609
|
+
# linked to /compare/<localHEAD>...<upstream> and 404'd because <localHEAD>
|
|
610
|
+
# was never pushed to the canonical repo.
|
|
611
|
+
#
|
|
612
|
+
# The right base is the merge-base between HEAD and the upstream ref —
|
|
613
|
+
# that's the most recent commit both sides agree on, and (because
|
|
614
|
+
# `git fetch` succeeded above) it is guaranteed to be present upstream.
|
|
615
|
+
# If a user is 17 commits behind with no local-only commits, merge-base
|
|
616
|
+
# equals local HEAD and the URL is identical to what we shipped before;
|
|
617
|
+
# if they ARE ahead with local-only commits, the URL still resolves to
|
|
618
|
+
# the public history they share with upstream. If merge-base fails for
|
|
619
|
+
# any reason (e.g. shallow clone where the bases diverge before the
|
|
620
|
+
# cutoff), fall back to None so the JS link guard suppresses the link
|
|
621
|
+
# rather than emitting a known-broken URL.
|
|
622
|
+
mb_full, mb_ok = _run_git(['merge-base', 'HEAD', compare_ref], path)
|
|
623
|
+
if mb_ok and mb_full:
|
|
624
|
+
short, ok = _run_git(['rev-parse', '--short', mb_full], path)
|
|
625
|
+
current = short if (ok and short) else None
|
|
626
|
+
else:
|
|
627
|
+
current = None
|
|
628
|
+
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
|
|
629
|
+
|
|
630
|
+
# Get repo URL for "What's new?" link
|
|
631
|
+
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
|
632
|
+
remote_url = _normalize_remote_url(remote_url)
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
'name': name,
|
|
636
|
+
'behind': behind,
|
|
637
|
+
'current_sha': current,
|
|
638
|
+
'latest_sha': latest,
|
|
639
|
+
'branch': compare_ref,
|
|
640
|
+
'repo_url': remote_url,
|
|
641
|
+
'compare_url': _build_compare_url(remote_url, current, latest),
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _check_repo(path, name):
|
|
646
|
+
"""Check if a git repo is behind its latest release. Returns dict or None."""
|
|
647
|
+
if path is None or not (path / '.git').exists():
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
# Fetch tags first so update prompts track published releases, not every
|
|
651
|
+
# development commit that lands on master/main after the latest release.
|
|
652
|
+
#
|
|
653
|
+
# --force is required because the WebUI is a release-tracking consumer:
|
|
654
|
+
# it never pushes tags, so it should always defer to whatever the remote
|
|
655
|
+
# says a release tag points to. Without --force, a remote re-tag (e.g.
|
|
656
|
+
# after a squash-merge that re-points a release tag at a new SHA) jams
|
|
657
|
+
# the update path indefinitely with "would clobber existing tag" errors.
|
|
658
|
+
# See #2756.
|
|
659
|
+
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
|
|
660
|
+
if not fetch_ok:
|
|
661
|
+
release_info = _check_repo_release(path, name)
|
|
662
|
+
message = 'fetch failed'
|
|
663
|
+
if fetch_out:
|
|
664
|
+
message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}'
|
|
665
|
+
if release_info is not None:
|
|
666
|
+
release_info = dict(release_info)
|
|
667
|
+
release_info['error'] = message
|
|
668
|
+
release_info['stale_check'] = True
|
|
669
|
+
return release_info
|
|
670
|
+
return {
|
|
671
|
+
'name': name,
|
|
672
|
+
'behind': None,
|
|
673
|
+
'error': message,
|
|
674
|
+
'stale_check': True,
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
release_info = _check_repo_release(path, name)
|
|
678
|
+
if release_info is not None:
|
|
679
|
+
return release_info
|
|
680
|
+
|
|
681
|
+
return _check_repo_branch(path, name, fetch=False)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _ignored_agent_update_info() -> dict:
|
|
685
|
+
"""Return a stable update-check payload for intentionally ignored Agent updates."""
|
|
686
|
+
return {'name': 'agent', 'behind': 0, 'ignored': True}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def check_for_updates(force=False, *, include_agent=True):
|
|
690
|
+
"""Return cached update status for webui and agent repos."""
|
|
691
|
+
global _check_in_progress
|
|
692
|
+
include_agent = bool(include_agent)
|
|
693
|
+
with _cache_lock:
|
|
694
|
+
if (
|
|
695
|
+
not force
|
|
696
|
+
and _update_cache.get('include_agent') == include_agent
|
|
697
|
+
and time.time() - _update_cache['checked_at'] < CACHE_TTL
|
|
698
|
+
):
|
|
699
|
+
return dict(_update_cache)
|
|
700
|
+
if _check_in_progress:
|
|
701
|
+
return dict(_update_cache) # another thread is already checking
|
|
702
|
+
_check_in_progress = True
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
# Run checks outside the lock (network I/O)
|
|
706
|
+
webui_info = _check_repo(REPO_ROOT, 'webui')
|
|
707
|
+
agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info()
|
|
708
|
+
|
|
709
|
+
with _cache_lock:
|
|
710
|
+
_update_cache['webui'] = webui_info
|
|
711
|
+
_update_cache['agent'] = agent_info
|
|
712
|
+
_update_cache['checked_at'] = time.time()
|
|
713
|
+
_update_cache['include_agent'] = include_agent
|
|
714
|
+
return dict(_update_cache)
|
|
715
|
+
finally:
|
|
716
|
+
_check_in_progress = False
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _repo_path_for_update_target(target: str):
|
|
720
|
+
if target == 'webui':
|
|
721
|
+
return REPO_ROOT
|
|
722
|
+
if target == 'agent':
|
|
723
|
+
return _AGENT_DIR
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _commit_subjects_for_update(info: dict, *, limit: int = 24) -> list[str]:
|
|
728
|
+
"""Return commit subjects for an update range, if the local git refs exist."""
|
|
729
|
+
subjects, _truncated = _commit_subjects_for_update_with_limit(info, limit=limit)
|
|
730
|
+
return subjects
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _commit_subjects_for_update_with_limit(info: dict, *, limit: int = 24) -> tuple[list[str], bool]:
|
|
734
|
+
"""Return recent commit subjects plus whether the local list was capped."""
|
|
735
|
+
if not isinstance(info, dict):
|
|
736
|
+
return [], False
|
|
737
|
+
target = info.get('name')
|
|
738
|
+
if target not in ('webui', 'agent'):
|
|
739
|
+
target = 'webui' if info.get('repo_url', '').endswith('hermes-webui') else target
|
|
740
|
+
path = _repo_path_for_update_target(target)
|
|
741
|
+
if path is None or not (Path(path) / '.git').exists():
|
|
742
|
+
return [], False
|
|
743
|
+
current = str(info.get('current_sha') or '').strip()
|
|
744
|
+
latest = str(info.get('latest_sha') or '').strip()
|
|
745
|
+
if not (current and latest):
|
|
746
|
+
return [], False
|
|
747
|
+
probe_limit = max(1, int(limit)) + 1
|
|
748
|
+
out, ok = _run_git(['log', '--format=%s', f'{current}..{latest}', f'-n{probe_limit}'], path, timeout=5)
|
|
749
|
+
if not ok or not out:
|
|
750
|
+
return [], False
|
|
751
|
+
subjects = [line.strip() for line in out.splitlines() if line.strip()]
|
|
752
|
+
truncated = len(subjects) > limit
|
|
753
|
+
return subjects[:limit], truncated
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _summary_cache_key(updates: dict, details: list[dict]) -> str:
|
|
757
|
+
"""Stable key for the exact update range being summarized."""
|
|
758
|
+
payload = []
|
|
759
|
+
for item in details:
|
|
760
|
+
payload.append({
|
|
761
|
+
'name': item.get('name'),
|
|
762
|
+
'behind': item.get('behind'),
|
|
763
|
+
'current_sha': item.get('current_sha'),
|
|
764
|
+
'latest_sha': item.get('latest_sha'),
|
|
765
|
+
'compare_url': item.get('compare_url'),
|
|
766
|
+
})
|
|
767
|
+
blob = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
|
768
|
+
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _clean_summary_bullet(line: str) -> str:
|
|
772
|
+
line = re.sub(r'^\s*(?:[-*•]+|\d+[.)])\s*', '', str(line or '')).strip()
|
|
773
|
+
line = re.sub(r'\s+', ' ', line)
|
|
774
|
+
if not line:
|
|
775
|
+
return ''
|
|
776
|
+
if line[-1] not in '.!?':
|
|
777
|
+
line += '.'
|
|
778
|
+
return line[:240]
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _split_summary_category(line: str) -> tuple[str | None, str]:
|
|
782
|
+
raw = str(line or '').strip()
|
|
783
|
+
match = re.match(r'^\s*(?:[-*•]+|\d+[.)])?\s*(notice|what you(?:ll|\'ll| will) notice|user(?:s)? will notice|worth knowing|worth|note)\s*:\s*(.+)$', raw, re.I)
|
|
784
|
+
if not match:
|
|
785
|
+
return None, raw
|
|
786
|
+
label = match.group(1).lower()
|
|
787
|
+
category = 'worth' if label in {'worth knowing', 'worth', 'note'} else 'notice'
|
|
788
|
+
return category, match.group(2)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _unique_summary_bullets(items: list[str]) -> list[str]:
|
|
792
|
+
seen = set()
|
|
793
|
+
bullets = []
|
|
794
|
+
for item in items:
|
|
795
|
+
cleaned = _clean_summary_bullet(item)
|
|
796
|
+
key = cleaned.lower()
|
|
797
|
+
if cleaned and key not in seen:
|
|
798
|
+
bullets.append(cleaned)
|
|
799
|
+
seen.add(key)
|
|
800
|
+
return bullets
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _summary_bullets_from_text(text: str, *, fallback_items: list[str]) -> list[str]:
|
|
804
|
+
raw = str(text or '').strip()
|
|
805
|
+
candidates = []
|
|
806
|
+
for line in raw.splitlines():
|
|
807
|
+
_category, body = _split_summary_category(line)
|
|
808
|
+
cleaned = _clean_summary_bullet(body)
|
|
809
|
+
if cleaned:
|
|
810
|
+
candidates.append(cleaned)
|
|
811
|
+
if len(candidates) <= 1 and raw:
|
|
812
|
+
candidates = [_clean_summary_bullet(part) for part in re.split(r'(?<=[.!?])\s+', raw)]
|
|
813
|
+
candidates = [item for item in candidates if item]
|
|
814
|
+
if not candidates:
|
|
815
|
+
candidates = [_clean_summary_bullet(item) for item in fallback_items]
|
|
816
|
+
bullets = _unique_summary_bullets(candidates)
|
|
817
|
+
return bullets or ['Updates are available.']
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _categorized_summary_bullets_from_text(text: str) -> tuple[list[str], list[str]]:
|
|
821
|
+
notice_items: list[str] = []
|
|
822
|
+
worth_items: list[str] = []
|
|
823
|
+
for line in str(text or '').splitlines():
|
|
824
|
+
category, body = _split_summary_category(line)
|
|
825
|
+
if category == 'notice':
|
|
826
|
+
notice_items.append(body)
|
|
827
|
+
elif category == 'worth':
|
|
828
|
+
worth_items.append(body)
|
|
829
|
+
elif re.match(r'^\s*(?:[-*•]+|\d+[.)])?\s*[A-Za-z][A-Za-z ]{1,32}\s*:', str(line or '')):
|
|
830
|
+
notice_items.append(body)
|
|
831
|
+
return _unique_summary_bullets(notice_items), _unique_summary_bullets(worth_items)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _fallback_update_bullets(details: list[dict]) -> list[str]:
|
|
835
|
+
bullets = []
|
|
836
|
+
for item in details:
|
|
837
|
+
label = item.get('label') or item.get('name') or 'Hermes'
|
|
838
|
+
behind = item.get('behind') or 0
|
|
839
|
+
commits = item.get('commits') or []
|
|
840
|
+
if commits:
|
|
841
|
+
highlights = '; '.join(commits[:3])
|
|
842
|
+
qualifier = 'recent updates' if item.get('commits_truncated') else 'updates'
|
|
843
|
+
bullets.append(f"{label} has {behind} update(s), including {qualifier}: {highlights}.")
|
|
844
|
+
else:
|
|
845
|
+
bullets.append(f"{label} has {behind} update(s) available.")
|
|
846
|
+
return bullets or ['Updates are available.']
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _worth_knowing_bullets(details: list[dict]) -> list[str]:
|
|
850
|
+
items = []
|
|
851
|
+
truncated = [item for item in details if item.get('commits_truncated') and item.get('commits_limit')]
|
|
852
|
+
for item in truncated[:2]:
|
|
853
|
+
label = item.get('label') or item.get('name') or 'Hermes'
|
|
854
|
+
behind = item.get('behind') or 0
|
|
855
|
+
limit = item.get('commits_limit') or len(item.get('commits') or [])
|
|
856
|
+
items.append(
|
|
857
|
+
f"{label} has {behind} updates; this summary uses the latest {limit} commit subjects, with the full comparison still available in the diff link."
|
|
858
|
+
)
|
|
859
|
+
if items:
|
|
860
|
+
return items
|
|
861
|
+
targets = [
|
|
862
|
+
f"{item.get('label') or item.get('name') or 'Hermes'} ({item.get('behind') or 0} update{'s' if (item.get('behind') or 0) != 1 else ''})"
|
|
863
|
+
for item in details
|
|
864
|
+
if item.get('behind')
|
|
865
|
+
]
|
|
866
|
+
if len(targets) > 1:
|
|
867
|
+
return ['This summary combines updates from ' + ' and '.join(targets) + '.']
|
|
868
|
+
return []
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _format_update_summary_sections(summary_text: str, details: list[dict]) -> tuple[list[dict], str]:
|
|
872
|
+
notice_items, worth_items = _categorized_summary_bullets_from_text(summary_text)
|
|
873
|
+
if not notice_items:
|
|
874
|
+
notice_items = _summary_bullets_from_text(summary_text, fallback_items=_fallback_update_bullets(details))
|
|
875
|
+
notice_keys = {item.lower() for item in notice_items}
|
|
876
|
+
worth_items = [item for item in worth_items if item.lower() not in notice_keys]
|
|
877
|
+
worth_items.extend(
|
|
878
|
+
item for item in _worth_knowing_bullets(details)
|
|
879
|
+
if item.lower() not in notice_keys and item.lower() not in {existing.lower() for existing in worth_items}
|
|
880
|
+
)
|
|
881
|
+
sections = [
|
|
882
|
+
{
|
|
883
|
+
'title': "What you'll notice",
|
|
884
|
+
'items': notice_items,
|
|
885
|
+
},
|
|
886
|
+
]
|
|
887
|
+
if worth_items:
|
|
888
|
+
sections.append(
|
|
889
|
+
{
|
|
890
|
+
'title': 'Worth knowing',
|
|
891
|
+
'items': worth_items,
|
|
892
|
+
}
|
|
893
|
+
)
|
|
894
|
+
lines = []
|
|
895
|
+
for section in sections:
|
|
896
|
+
lines.append(section['title'])
|
|
897
|
+
lines.extend(f"- {item}" for item in section['items'])
|
|
898
|
+
lines.append('')
|
|
899
|
+
return sections, '\n'.join(lines).strip()
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _fallback_update_summary(updates: dict, details: list[dict]) -> str:
|
|
903
|
+
_sections, summary = _format_update_summary_sections('', details)
|
|
904
|
+
return summary
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _update_summary_prompt(details: list[dict]) -> tuple[str, str]:
|
|
908
|
+
system = (
|
|
909
|
+
"You write human-readable release summaries for Hermes users. "
|
|
910
|
+
"Focus on what the user will notice in the product. Keep it simple, specific, and short. "
|
|
911
|
+
"avoid technical jargon, implementation details, SHA names, branch names, and file paths unless necessary. "
|
|
912
|
+
"Return only bullets. Do not include headings, markdown tables, intro paragraphs, or closing notes."
|
|
913
|
+
)
|
|
914
|
+
user_lines = [
|
|
915
|
+
"Summarize these available updates as concise bullets.",
|
|
916
|
+
"Prefix each bullet with `Notice:` for user-visible behavior changes or `Worth knowing:` for useful context.",
|
|
917
|
+
"Put user-visible Notice bullets first and include every meaningful user-facing change from the available commit subjects.",
|
|
918
|
+
"Use Worth knowing only for helpful context that is not a duplicate of a Notice bullet.",
|
|
919
|
+
"Use everyday language and explain visible behavior changes, not code mechanics.",
|
|
920
|
+
"Return only prefixed bullets; the WebUI will add the fixed section headings separately.",
|
|
921
|
+
"",
|
|
922
|
+
]
|
|
923
|
+
for item in details:
|
|
924
|
+
user_lines.append(f"{item['label']}: {item['behind']} commit(s) behind")
|
|
925
|
+
commits = item.get('commits') or []
|
|
926
|
+
if commits:
|
|
927
|
+
if item.get('commits_truncated'):
|
|
928
|
+
user_lines.append(
|
|
929
|
+
f"- Showing latest {len(commits)} of {item['behind']} commit subjects; summarize trends, not every commit."
|
|
930
|
+
)
|
|
931
|
+
user_lines.extend(f"- {subject}" for subject in commits)
|
|
932
|
+
else:
|
|
933
|
+
user_lines.append("- No local commit subjects available; summarize only the update count.")
|
|
934
|
+
user_lines.append("")
|
|
935
|
+
return system, '\n'.join(user_lines)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def summarize_update_payload(updates: dict, llm_callback=None, *, target: str | None = None, use_cache: bool = True) -> dict:
|
|
939
|
+
"""Build a human-readable What's New summary and keep regular diff comparison links.
|
|
940
|
+
|
|
941
|
+
``llm_callback`` receives ``(system_prompt, user_prompt)`` and returns text.
|
|
942
|
+
The caller may wire that to AIAgent; this module keeps a deterministic
|
|
943
|
+
fallback so the banner remains useful when no LLM provider is configured.
|
|
944
|
+
Summaries are cached per exact update range so refreshes do not generate
|
|
945
|
+
slightly different wording for the same available updates.
|
|
946
|
+
"""
|
|
947
|
+
if not isinstance(updates, dict):
|
|
948
|
+
updates = {}
|
|
949
|
+
requested_target = target if target in ('webui', 'agent') else None
|
|
950
|
+
details = []
|
|
951
|
+
for key, label in (('webui', 'WebUI'), ('agent', 'Agent')):
|
|
952
|
+
if requested_target and key != requested_target:
|
|
953
|
+
continue
|
|
954
|
+
info = updates.get(key)
|
|
955
|
+
if not isinstance(info, dict) or int(info.get('behind') or 0) <= 0:
|
|
956
|
+
continue
|
|
957
|
+
commit_limit = 24
|
|
958
|
+
commits, commits_truncated = _commit_subjects_for_update_with_limit({'name': key, **info}, limit=commit_limit)
|
|
959
|
+
behind = int(info.get('behind') or 0)
|
|
960
|
+
item = {
|
|
961
|
+
'name': key,
|
|
962
|
+
'label': label,
|
|
963
|
+
'behind': behind,
|
|
964
|
+
'current_sha': info.get('current_sha'),
|
|
965
|
+
'latest_sha': info.get('latest_sha'),
|
|
966
|
+
'compare_url': info.get('compare_url'),
|
|
967
|
+
'commits': commits,
|
|
968
|
+
'commits_limit': commit_limit,
|
|
969
|
+
'commits_truncated': bool(commits_truncated or (commits and behind > len(commits))),
|
|
970
|
+
}
|
|
971
|
+
details.append(item)
|
|
972
|
+
cache_key = _summary_cache_key(updates, details)
|
|
973
|
+
if use_cache:
|
|
974
|
+
with _cache_lock:
|
|
975
|
+
cached = _summary_cache.get(cache_key)
|
|
976
|
+
if cached:
|
|
977
|
+
_summary_cache.move_to_end(cache_key)
|
|
978
|
+
if cached:
|
|
979
|
+
result = dict(cached)
|
|
980
|
+
result['cached'] = True
|
|
981
|
+
return result
|
|
982
|
+
|
|
983
|
+
generated_by = 'fallback'
|
|
984
|
+
candidate = ''
|
|
985
|
+
if details and callable(llm_callback):
|
|
986
|
+
system, prompt = _update_summary_prompt(details)
|
|
987
|
+
try:
|
|
988
|
+
candidate = (llm_callback(system, prompt) or '').strip()
|
|
989
|
+
if candidate:
|
|
990
|
+
generated_by = 'llm'
|
|
991
|
+
except Exception:
|
|
992
|
+
candidate = ''
|
|
993
|
+
sections, summary = _format_update_summary_sections(candidate, details)
|
|
994
|
+
result = {
|
|
995
|
+
'ok': True,
|
|
996
|
+
'summary': summary,
|
|
997
|
+
'summary_sections': sections,
|
|
998
|
+
'generated_by': generated_by,
|
|
999
|
+
'cached': False,
|
|
1000
|
+
'cache_key': cache_key,
|
|
1001
|
+
'target': requested_target,
|
|
1002
|
+
'targets': details,
|
|
1003
|
+
}
|
|
1004
|
+
if use_cache:
|
|
1005
|
+
with _cache_lock:
|
|
1006
|
+
if len(_summary_cache) >= _SUMMARY_CACHE_MAX and cache_key not in _summary_cache:
|
|
1007
|
+
_summary_cache.popitem(last=False)
|
|
1008
|
+
_summary_cache[cache_key] = dict(result)
|
|
1009
|
+
return result
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
# ── Self-update application ───────────────────────────────────────────────────
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _schedule_restart(delay: float = 2.0) -> None:
|
|
1016
|
+
"""Re-exec this process after *delay* seconds.
|
|
1017
|
+
|
|
1018
|
+
Called after a successful update so that the freshly-pulled code is
|
|
1019
|
+
loaded on the next request, rather than running with a mix of old and
|
|
1020
|
+
new Python modules in sys.modules.
|
|
1021
|
+
|
|
1022
|
+
os.execv() replaces the current process image with a fresh interpreter
|
|
1023
|
+
running the same argv — sessions are preserved on disk, the HTTP port
|
|
1024
|
+
is reclaimed within the delay window, and the client's own
|
|
1025
|
+
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
|
|
1026
|
+
|
|
1027
|
+
Coordinates with ``_apply_lock``: when the user updates both webui
|
|
1028
|
+
and agent, the client POSTs them sequentially. Without coordination
|
|
1029
|
+
the restart timer scheduled by the first update's success would fire
|
|
1030
|
+
while the second update's git-pull is still running, killing it mid-
|
|
1031
|
+
stream and leaving the second repo in an unknown partial state.
|
|
1032
|
+
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
|
|
1033
|
+
second update always completes before the restart happens.
|
|
1034
|
+
"""
|
|
1035
|
+
import os
|
|
1036
|
+
import sys
|
|
1037
|
+
|
|
1038
|
+
def _do():
|
|
1039
|
+
import time
|
|
1040
|
+
time.sleep(delay)
|
|
1041
|
+
# Hold _apply_lock through os.execv so no new update can start between
|
|
1042
|
+
# the lock-release and the process replacement. Any in-flight update
|
|
1043
|
+
# finishes first (since it holds the lock), and then the process is
|
|
1044
|
+
# replaced while still holding the lock — meaning no new update can
|
|
1045
|
+
# sneak in during the brief TOCTOU window that existed with the
|
|
1046
|
+
# original acquire-release-execv sequence.
|
|
1047
|
+
# Threads die when execv replaces the process image, so the lock is
|
|
1048
|
+
# released atomically by the kernel.
|
|
1049
|
+
with _apply_lock:
|
|
1050
|
+
_wait_until_restart_safe()
|
|
1051
|
+
try:
|
|
1052
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
1053
|
+
except Exception:
|
|
1054
|
+
# Last-resort: if execv fails (e.g. frozen binary), just exit
|
|
1055
|
+
# so the process supervisor (start.sh / Docker) restarts us.
|
|
1056
|
+
os._exit(0)
|
|
1057
|
+
|
|
1058
|
+
threading.Thread(target=_do, daemon=True).start()
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def apply_force_update(target: str) -> dict:
|
|
1062
|
+
"""Force-reset the target repo to the latest remote HEAD.
|
|
1063
|
+
|
|
1064
|
+
Unlike apply_update() which requires a clean working tree and refuses
|
|
1065
|
+
merge conflicts, this discards all local modifications (checkout .) and
|
|
1066
|
+
resets to origin/<branch> — equivalent to what the diverged/conflict
|
|
1067
|
+
error messages ask the user to run manually.
|
|
1068
|
+
|
|
1069
|
+
Should only be called when apply_update() has already returned a
|
|
1070
|
+
response with ``conflict: True`` or ``diverged: True`` and the user
|
|
1071
|
+
has confirmed they want to discard local changes.
|
|
1072
|
+
"""
|
|
1073
|
+
blocker_snapshot = _restart_blocker_snapshot()
|
|
1074
|
+
if blocker_snapshot.get('restart_blocked'):
|
|
1075
|
+
return _restart_blocked_response(target, blocker_snapshot)
|
|
1076
|
+
|
|
1077
|
+
if not _apply_lock.acquire(blocking=False):
|
|
1078
|
+
return {'ok': False, 'message': 'Update already in progress'}
|
|
1079
|
+
try:
|
|
1080
|
+
if target == 'webui':
|
|
1081
|
+
path = REPO_ROOT
|
|
1082
|
+
elif target == 'agent':
|
|
1083
|
+
path = _AGENT_DIR
|
|
1084
|
+
else:
|
|
1085
|
+
return {'ok': False, 'message': f'Unknown target: {target}'}
|
|
1086
|
+
|
|
1087
|
+
if path is None or not (path / '.git').exists():
|
|
1088
|
+
return {'ok': False, 'message': 'Not a git repository'}
|
|
1089
|
+
|
|
1090
|
+
# --force so a remote re-tag (e.g. squash-merge that re-points an
|
|
1091
|
+
# existing release tag) doesn't jam the apply path with "would clobber
|
|
1092
|
+
# existing tag". See #2756.
|
|
1093
|
+
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
|
|
1094
|
+
if not fetch_ok:
|
|
1095
|
+
return {
|
|
1096
|
+
'ok': False,
|
|
1097
|
+
'message': 'Could not reach the remote repository. Check your connection.',
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
compare_ref = _select_apply_compare_ref(path)
|
|
1101
|
+
|
|
1102
|
+
# Discard local modifications then reset to remote HEAD
|
|
1103
|
+
_run_git(['checkout', '.'], path)
|
|
1104
|
+
_, ok = _run_git(['reset', '--hard', compare_ref], path)
|
|
1105
|
+
if not ok:
|
|
1106
|
+
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
|
|
1107
|
+
|
|
1108
|
+
with _cache_lock:
|
|
1109
|
+
_update_cache['checked_at'] = 0
|
|
1110
|
+
|
|
1111
|
+
_schedule_restart()
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
'ok': True,
|
|
1115
|
+
'message': f'{target} force-updated to {compare_ref}',
|
|
1116
|
+
'target': target,
|
|
1117
|
+
'restart_scheduled': True,
|
|
1118
|
+
}
|
|
1119
|
+
finally:
|
|
1120
|
+
_apply_lock.release()
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def apply_update(target):
|
|
1124
|
+
"""Stash, pull --ff-only, pop for the given target repo."""
|
|
1125
|
+
blocker_snapshot = _restart_blocker_snapshot()
|
|
1126
|
+
if blocker_snapshot.get('restart_blocked'):
|
|
1127
|
+
return _restart_blocked_response(target, blocker_snapshot)
|
|
1128
|
+
|
|
1129
|
+
if not _apply_lock.acquire(blocking=False):
|
|
1130
|
+
return {'ok': False, 'message': 'Update already in progress'}
|
|
1131
|
+
try:
|
|
1132
|
+
return _apply_update_inner(target)
|
|
1133
|
+
finally:
|
|
1134
|
+
_apply_lock.release()
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _apply_update_inner(target):
|
|
1138
|
+
"""Inner implementation of apply_update, called under _apply_lock."""
|
|
1139
|
+
if target == 'webui':
|
|
1140
|
+
path = REPO_ROOT
|
|
1141
|
+
elif target == 'agent':
|
|
1142
|
+
path = _AGENT_DIR
|
|
1143
|
+
else:
|
|
1144
|
+
return {'ok': False, 'message': f'Unknown target: {target}'}
|
|
1145
|
+
|
|
1146
|
+
if path is None or not (path / '.git').exists():
|
|
1147
|
+
return {'ok': False, 'message': 'Not a git repository'}
|
|
1148
|
+
|
|
1149
|
+
# Fetch before attempting pull, so the remote ref is current.
|
|
1150
|
+
# --force so a remote re-tag doesn't block the update path (see #2756).
|
|
1151
|
+
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
|
|
1152
|
+
if not fetch_ok:
|
|
1153
|
+
return {
|
|
1154
|
+
'ok': False,
|
|
1155
|
+
'message': (
|
|
1156
|
+
'Could not reach the remote repository. '
|
|
1157
|
+
'Check your internet connection and try again.'
|
|
1158
|
+
),
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
compare_ref = _select_apply_compare_ref(path)
|
|
1162
|
+
|
|
1163
|
+
# Check for dirty working tree (ignore untracked files — git stash
|
|
1164
|
+
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
|
|
1165
|
+
status_out, status_ok = _run_git(
|
|
1166
|
+
['status', '--porcelain', '--untracked-files=no'], path
|
|
1167
|
+
)
|
|
1168
|
+
if not status_ok:
|
|
1169
|
+
return {'ok': False, 'message': f'Failed to inspect repo status: {status_out[:200]}'}
|
|
1170
|
+
# Fail early on unresolved merge conflicts
|
|
1171
|
+
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
|
|
1172
|
+
for line in status_out.splitlines()):
|
|
1173
|
+
return {
|
|
1174
|
+
'ok': False,
|
|
1175
|
+
'message': (
|
|
1176
|
+
f'The local {target} repo has unresolved merge conflicts. '
|
|
1177
|
+
'To reset to the latest remote version run: '
|
|
1178
|
+
'git -C ' + str(path) + ' checkout . && '
|
|
1179
|
+
'git -C ' + str(path) + ' pull --ff-only'
|
|
1180
|
+
),
|
|
1181
|
+
'conflict': True,
|
|
1182
|
+
}
|
|
1183
|
+
stashed = False
|
|
1184
|
+
if status_out:
|
|
1185
|
+
_, ok = _run_git(['stash'], path)
|
|
1186
|
+
if not ok:
|
|
1187
|
+
return {'ok': False, 'message': 'Failed to stash local changes'}
|
|
1188
|
+
stashed = True
|
|
1189
|
+
|
|
1190
|
+
# Pull with ff-only (no merge commits).
|
|
1191
|
+
# Split tracking refs like 'origin/main' into separate remote + branch
|
|
1192
|
+
# arguments — git treats 'origin/main' as a repository name otherwise.
|
|
1193
|
+
remote, branch = _split_remote_ref(compare_ref)
|
|
1194
|
+
pull_args = ['pull', '--ff-only']
|
|
1195
|
+
if remote:
|
|
1196
|
+
pull_args.extend([remote, branch])
|
|
1197
|
+
else:
|
|
1198
|
+
pull_args.extend(['origin', compare_ref])
|
|
1199
|
+
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
|
|
1200
|
+
if not pull_ok:
|
|
1201
|
+
if stashed:
|
|
1202
|
+
_run_git(['stash', 'pop'], path)
|
|
1203
|
+
|
|
1204
|
+
# Diagnose the most common failure modes and surface actionable messages.
|
|
1205
|
+
pull_lower = pull_out.lower()
|
|
1206
|
+
if 'not possible to fast-forward' in pull_lower or 'diverged' in pull_lower:
|
|
1207
|
+
return {
|
|
1208
|
+
'ok': False,
|
|
1209
|
+
'message': (
|
|
1210
|
+
f'The local {target} repo has commits that are not on the remote '
|
|
1211
|
+
'branch, so a fast-forward update is not possible. '
|
|
1212
|
+
'Run: git -C ' + str(path) + ' fetch origin && '
|
|
1213
|
+
'git -C ' + str(path) + ' reset --hard ' + compare_ref
|
|
1214
|
+
),
|
|
1215
|
+
'diverged': True,
|
|
1216
|
+
}
|
|
1217
|
+
if 'does not track' in pull_lower or 'no tracking information' in pull_lower:
|
|
1218
|
+
return {
|
|
1219
|
+
'ok': False,
|
|
1220
|
+
'message': (
|
|
1221
|
+
f'The local {target} branch has no upstream tracking branch configured. '
|
|
1222
|
+
'Run: git -C ' + str(path) + ' branch --set-upstream-to=' + compare_ref
|
|
1223
|
+
),
|
|
1224
|
+
}
|
|
1225
|
+
# Generic fallback — include the raw git output for debugging.
|
|
1226
|
+
detail = pull_out.strip()[:300] if pull_out.strip() else '(no output from git)'
|
|
1227
|
+
return {'ok': False, 'message': f'Pull failed: {detail}'}
|
|
1228
|
+
|
|
1229
|
+
# Pop stash if we stashed
|
|
1230
|
+
if stashed:
|
|
1231
|
+
_, pop_ok = _run_git(['stash', 'pop'], path)
|
|
1232
|
+
if not pop_ok:
|
|
1233
|
+
return {
|
|
1234
|
+
'ok': False,
|
|
1235
|
+
'message': 'Updated but stash pop failed -- manual merge needed',
|
|
1236
|
+
'stash_conflict': True,
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
# Invalidate cache
|
|
1240
|
+
with _cache_lock:
|
|
1241
|
+
_update_cache['checked_at'] = 0
|
|
1242
|
+
|
|
1243
|
+
# Schedule a self-restart so the updated code is loaded fresh. A plain
|
|
1244
|
+
# git pull leaves stale Python modules in sys.modules — agent imports that
|
|
1245
|
+
# reference new symbols (functions, classes) added in the update will fail
|
|
1246
|
+
# on the next request with AttributeError / ImportError. os.execv() re-
|
|
1247
|
+
# execs the same interpreter with the same argv, picking up the new code
|
|
1248
|
+
# cleanly without requiring the user to restart manually.
|
|
1249
|
+
#
|
|
1250
|
+
# The 2 s delay gives the HTTP response time to flush to the client before
|
|
1251
|
+
# the process replaces itself. The client already does
|
|
1252
|
+
# setTimeout(() => location.reload(), 1500) on success, so the page reload
|
|
1253
|
+
# and the restart land at roughly the same time.
|
|
1254
|
+
_schedule_restart()
|
|
1255
|
+
|
|
1256
|
+
return {
|
|
1257
|
+
'ok': True,
|
|
1258
|
+
'message': f'{target} updated successfully',
|
|
1259
|
+
'target': target,
|
|
1260
|
+
'restart_scheduled': True,
|
|
1261
|
+
}
|