fix preview runtime isolation and shutdown (#1088)

This commit is contained in:
ekko
2026-05-28 13:50:52 +08:00
committed by GitHub
parent 1734bac9b4
commit d610c3d1b9
17 changed files with 256 additions and 19 deletions
+39
View File
@@ -22,6 +22,9 @@ const TOKEN_FILE = join(PID_DIR, '.token')
const LOGIN_LOCK_FILE = join(WEB_UI_HOME, '.login-lock.json') const LOGIN_LOCK_FILE = join(WEB_UI_HOME, '.login-lock.json')
const WEB_UI_DB_FILE = join(WEB_UI_HOME, 'hermes-web-ui.db') const WEB_UI_DB_FILE = join(WEB_UI_HOME, 'hermes-web-ui.db')
const DEFAULT_PORT = 8648 const DEFAULT_PORT = 8648
const PREVIEW_BACKEND_PORT = 8650
const PREVIEW_FRONTEND_PORT = 8651
const PREVIEW_AGENT_BRIDGE_PORT = 18650
const DEFAULT_USERNAME = 'admin' const DEFAULT_USERNAME = 'admin'
const DEFAULT_PASSWORD = '123456' const DEFAULT_PASSWORD = '123456'
@@ -261,6 +264,37 @@ function killListeningPids(port, pids = getListeningPids(port)) {
} catch {} } catch {}
} }
function stopPreviewRuntimeFromCli() {
const previewPorts = [
PREVIEW_BACKEND_PORT,
PREVIEW_FRONTEND_PORT,
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
]
const pids = [...new Set(previewPorts.flatMap(port => getListeningPids(port)))]
if (!pids.length) return 0
console.log(` ⏹ Stopping preview runtime (PID(s): ${pids.join(' ')})...`)
for (const pid of pids) {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -TERM -${pid}`, { stdio: 'ignore' })
}
} catch {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -9 ${pid}`, { stdio: 'ignore' })
}
} catch {}
}
}
return pids.length
}
function recoverPidFromPort() { function recoverPidFromPort() {
const port = getPortFromArgs() ?? DEFAULT_PORT const port = getPortFromArgs() ?? DEFAULT_PORT
for (const pid of getListeningPids(port)) { for (const pid of getListeningPids(port)) {
@@ -419,6 +453,7 @@ function startDaemon(port) {
} }
function stopDaemon() { function stopDaemon() {
const stoppedPreviewPids = stopPreviewRuntimeFromCli()
const pidFromFile = readPidFile() const pidFromFile = readPidFile()
if (pidFromFile && !isRunning(pidFromFile)) { if (pidFromFile && !isRunning(pidFromFile)) {
removePid() removePid()
@@ -428,6 +463,10 @@ function stopDaemon() {
const pid = pidFromFile ?? recoverPidFromPort() const pid = pidFromFile ?? recoverPidFromPort()
if (!pid) { if (!pid) {
if (stoppedPreviewPids) {
console.log(` ✓ hermes-web-ui preview stopped`)
return
}
console.log(' ✗ hermes-web-ui is not running') console.log(' ✗ hermes-web-ui is not running')
process.exit(1) process.exit(1)
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hermes-web-ui", "name": "hermes-web-ui",
"version": "0.6.3", "version": "0.6.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hermes-web-ui", "name": "hermes-web-ui",
"version": "0.6.3", "version": "0.6.4",
"license": "BSL-1.1", "license": "BSL-1.1",
"dependencies": { "dependencies": {
"@vscode/markdown-it-katex": "^1.1.2", "@vscode/markdown-it-katex": "^1.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hermes-web-ui", "name": "hermes-web-ui",
"version": "0.6.3", "version": "0.6.4",
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
"repository": { "repository": {
"type": "git", "type": "git",
+13
View File
@@ -5,6 +5,19 @@ export interface ChangelogEntry {
} }
export const changelog: ChangelogEntry[] = [ export const changelog: ChangelogEntry[] = [
{
version: '0.6.4',
date: '2026-05-28',
changes: [
'changelog.new_0_6_4_1',
'changelog.new_0_6_4_2',
'changelog.new_0_6_4_3',
'changelog.new_0_6_4_4',
'changelog.new_0_6_4_5',
'changelog.new_0_6_4_6',
'changelog.new_0_6_4_7',
],
},
{ {
version: '0.6.3', version: '0.6.3',
date: '2026-05-27', date: '2026-05-27',
+7
View File
@@ -1106,6 +1106,13 @@ jobTriggered: 'Job ausgelost',
// Anderungsprotokoll // Anderungsprotokoll
changelog: { changelog: {
new_0_6_4_1: 'CI wurde mit festem npm-Installationsverhalten und Docker-Smoke-Coverage für PRs gehärtet',
new_0_6_4_2: 'Chat nutzt jetzt virtualisierte Pagination, damit lange Gespräche zuverlässiger scrollen und laden',
new_0_6_4_3: 'Docker-Image-Publishing läuft jetzt nur noch für Releases statt für normale PR-Checks',
new_0_6_4_4: 'Version Preview ist für Super-Admins verfügbar, mit main/tag-Auswahl, Preview-Checkout, Dependency-Installation, Start/Stop und Logs',
new_0_6_4_5: 'Preview-Instanzen isolieren Frontend/Backend-Ports, Web UI home und agent bridge endpoint, inklusive Runtime-Patches für ältere Tags zu Ports, WebSocket-Routing, base URL und verschachtelter Preview-Navigation',
new_0_6_4_6: 'Legacy session_usage Tabellen ohne created_at werden jetzt sicher mit einem Standardwert migriert',
new_0_6_4_7: 'Bridge profile worker endpoints werden jetzt nach broker endpoint getrennt, damit Produktion und Preview mit gleichem Profile keine worker sockets stehlen und keine unknown run Fehler auslösen',
new_0_6_3_1: 'Bridge-Spinner-Status wird nicht mehr als Modell-Reasoning gespeichert und verschmutzt dadurch keinen späteren Kontext', new_0_6_3_1: 'Bridge-Spinner-Status wird nicht mehr als Modell-Reasoning gespeichert und verschmutzt dadurch keinen späteren Kontext',
new_0_6_3_2: 'History bietet jetzt Controls zum Import von Hermes-CLI-Sessions in die lokale Web-UI-Historie mit sichererer Nachrichten-Normalisierung', new_0_6_3_2: 'History bietet jetzt Controls zum Import von Hermes-CLI-Sessions in die lokale Web-UI-Historie mit sichererer Nachrichten-Normalisierung',
new_0_6_3_3: 'Provider-Setup unterstützt editierbare eingebaute Base URLs, LM Studio als eingebauten Provider und Live-Erkennung über LM Studio /models', new_0_6_3_3: 'Provider-Setup unterstützt editierbare eingebaute Base URLs, LM Studio als eingebauten Provider und Live-Erkennung über LM Studio /models',
+7
View File
@@ -1328,6 +1328,13 @@ export default {
// Changelog // Changelog
changelog: { changelog: {
new_0_6_4_1: 'CI is hardened with pinned npm install behavior and PR Docker smoke coverage',
new_0_6_4_2: 'Chat now uses virtualized pagination so long conversations scroll and load more reliably',
new_0_6_4_3: 'Docker image publishing now runs only for releases instead of ordinary PR checks',
new_0_6_4_4: 'Version Preview is available to super admins, with main/tag selection, preview checkout, dependency install, start/stop controls, and logs',
new_0_6_4_5: 'Preview instances isolate frontend/backend ports, Web UI home, and agent bridge endpoint, with runtime patches for older tags covering ports, WebSocket routing, base URL behavior, and nested preview navigation',
new_0_6_4_6: 'Legacy session_usage tables missing created_at now migrate safely with a default value',
new_0_6_4_7: 'Bridge profile worker endpoints are now namespaced by broker endpoint, preventing production and preview instances with the same Profile from stealing worker sockets and causing unknown run errors',
new_0_6_3_1: 'Bridge spinner status is no longer stored as model reasoning, preventing decorative thinking text from contaminating future context', new_0_6_3_1: 'Bridge spinner status is no longer stored as model reasoning, preventing decorative thinking text from contaminating future context',
new_0_6_3_2: 'History now includes controls to import Hermes CLI sessions into the Web UI local history with safer message normalization', new_0_6_3_2: 'History now includes controls to import Hermes CLI sessions into the Web UI local history with safer message normalization',
new_0_6_3_3: 'Provider setup now supports editable built-in base URLs, LM Studio as a built-in provider, and live LM Studio /models discovery', new_0_6_3_3: 'Provider setup now supports editable built-in base URLs, LM Studio as a built-in provider, and live LM Studio /models discovery',
+7
View File
@@ -1106,6 +1106,13 @@ jobTriggered: 'Job ejecutado',
// Registro de cambios // Registro de cambios
changelog: { changelog: {
new_0_6_4_1: 'CI se refuerza con instalación npm fijada y cobertura Docker smoke para PRs',
new_0_6_4_2: 'El chat ahora usa paginación virtualizada para que conversaciones largas se desplacen y carguen con más fiabilidad',
new_0_6_4_3: 'La publicación de imágenes Docker ahora solo se ejecuta en releases, no en checks normales de PR',
new_0_6_4_4: 'Version Preview está disponible para super admins, con selección main/tag, checkout de preview, instalación de dependencias, controles start/stop y logs',
new_0_6_4_5: 'Las instancias preview aíslan puertos frontend/backend, Web UI home y agent bridge endpoint, con parches runtime para tags antiguos que cubren puertos, WebSocket, base URL y navegación preview anidada',
new_0_6_4_6: 'Las tablas legacy session_usage sin created_at ahora migran de forma segura con un valor predeterminado',
new_0_6_4_7: 'Los endpoints de bridge profile worker ahora se separan por broker endpoint, evitando que producción y preview con el mismo Profile se roben worker sockets y causen errores unknown run',
new_0_6_3_1: 'El estado del spinner de Bridge ya no se guarda como reasoning del modelo, evitando que texto decorativo de thinking contamine el contexto futuro', new_0_6_3_1: 'El estado del spinner de Bridge ya no se guarda como reasoning del modelo, evitando que texto decorativo de thinking contamine el contexto futuro',
new_0_6_3_2: 'History ahora incluye controles para importar sesiones de Hermes CLI al historial local de Web UI con normalizacion de mensajes mas segura', new_0_6_3_2: 'History ahora incluye controles para importar sesiones de Hermes CLI al historial local de Web UI con normalizacion de mensajes mas segura',
new_0_6_3_3: 'La configuracion de Provider admite base URLs integradas editables, LM Studio como provider integrado y descubrimiento en vivo desde LM Studio /models', new_0_6_3_3: 'La configuracion de Provider admite base URLs integradas editables, LM Studio como provider integrado y descubrimiento en vivo desde LM Studio /models',
+7
View File
@@ -1106,6 +1106,13 @@ jobTriggered: 'Job declenche',
// Journal des modifications // Journal des modifications
changelog: { changelog: {
new_0_6_4_1: 'CI est renforcé avec une installation npm figée et une couverture Docker smoke pour les PR',
new_0_6_4_2: 'Le chat utilise maintenant une pagination virtualisée pour rendre les longues conversations plus fiables au scroll et au chargement',
new_0_6_4_3: 'La publication des images Docker ne s exécute désormais que pour les releases, pas pour les checks PR ordinaires',
new_0_6_4_4: 'Version Preview est disponible pour les super admins, avec sélection main/tag, checkout preview, installation des dépendances, start/stop et logs',
new_0_6_4_5: 'Les instances preview isolent les ports frontend/backend, le Web UI home et l agent bridge endpoint, avec des patches runtime pour les anciens tags couvrant ports, WebSocket, base URL et navigation preview imbriquée',
new_0_6_4_6: 'Les tables legacy session_usage sans created_at migrent maintenant correctement avec une valeur par défaut',
new_0_6_4_7: 'Les endpoints bridge profile worker sont maintenant séparés par broker endpoint, évitant que production et preview avec le même Profile se volent les worker sockets et provoquent des erreurs unknown run',
new_0_6_3_1: 'Le statut du spinner Bridge n est plus stocke comme reasoning du modele, evitant que du texte thinking decoratif contamine le contexte futur', new_0_6_3_1: 'Le statut du spinner Bridge n est plus stocke comme reasoning du modele, evitant que du texte thinking decoratif contamine le contexte futur',
new_0_6_3_2: 'History ajoute des controles pour importer les sessions Hermes CLI dans l historique local Web UI avec une normalisation de messages plus sure', new_0_6_3_2: 'History ajoute des controles pour importer les sessions Hermes CLI dans l historique local Web UI avec une normalisation de messages plus sure',
new_0_6_3_3: 'La configuration Provider prend en charge les base URLs integrees editables, LM Studio comme provider integre et la decouverte live via LM Studio /models', new_0_6_3_3: 'La configuration Provider prend en charge les base URLs integrees editables, LM Studio comme provider integre et la decouverte live via LM Studio /models',
+7
View File
@@ -1105,6 +1105,13 @@ export default {
// 更新履歴 // 更新履歴
changelog: { changelog: {
new_0_6_4_1: 'CI を強化し、npm install の挙動を固定して PR の Docker smoke チェックを追加しました',
new_0_6_4_2: 'チャットに仮想ページングを追加し、長い会話のスクロールと読み込みをより安定させました',
new_0_6_4_3: 'Docker イメージ公開は通常の PR チェックではなく release の場合のみ実行されます',
new_0_6_4_4: 'Version Preview を super admin 向けに追加し、main/tag 選択、preview checkout、依存関係インストール、start/stop、ログ確認に対応しました',
new_0_6_4_5: 'Preview インスタンスは frontend/backend ポート、Web UI home、agent bridge endpoint を分離し、古い tag にはポート、WebSocket、base URL、ネストした preview ナビゲーションの runtime patch を適用します',
new_0_6_4_6: 'created_at がない legacy session_usage テーブルをデフォルト値付きで安全に移行します',
new_0_6_4_7: 'Bridge profile worker endpoint を broker endpoint ごとに分離し、同じ Profile の本番/preview が worker socket を奪い合って unknown run を起こす問題を防ぎます',
new_0_6_3_1: 'Bridge spinner の状態をモデル reasoning として保存しないようにし、装飾的な thinking テキストが後続コンテキストを汚染しないようにしました', new_0_6_3_1: 'Bridge spinner の状態をモデル reasoning として保存しないようにし、装飾的な thinking テキストが後続コンテキストを汚染しないようにしました',
new_0_6_3_2: 'History に Hermes CLI セッションを Web UI のローカル履歴へインポートする操作を追加し、メッセージ構造をより安全に正規化します', new_0_6_3_2: 'History に Hermes CLI セッションを Web UI のローカル履歴へインポートする操作を追加し、メッセージ構造をより安全に正規化します',
new_0_6_3_3: 'Provider 設定で組み込み base URL を編集できるようになり、LM Studio を組み込み Provider として追加し、LM Studio /models のライブ取得に対応しました', new_0_6_3_3: 'Provider 設定で組み込み base URL を編集できるようになり、LM Studio を組み込み Provider として追加し、LM Studio /models のライブ取得に対応しました',
+7
View File
@@ -1105,6 +1105,13 @@ export default {
// 변경 이력 // 변경 이력
changelog: { changelog: {
new_0_6_4_1: 'CI를 강화해 npm install 동작을 고정하고 PR Docker smoke 검사를 추가했습니다',
new_0_6_4_2: '채팅에 가상 페이지네이션을 적용해 긴 대화의 스크롤과 로딩을 더 안정적으로 만들었습니다',
new_0_6_4_3: 'Docker 이미지 배포는 일반 PR 검사 대신 release 에서만 실행됩니다',
new_0_6_4_4: '슈퍼 관리자용 Version Preview 를 추가해 main/tag 선택, preview checkout, 의존성 설치, start/stop, 로그 확인을 지원합니다',
new_0_6_4_5: 'Preview 인스턴스는 frontend/backend 포트, Web UI home, agent bridge endpoint 를 분리하고 오래된 tag 에 대해 포트, WebSocket, base URL, 중첩 preview 내비게이션 runtime patch 를 적용합니다',
new_0_6_4_6: 'created_at 이 없는 legacy session_usage 테이블을 기본값으로 안전하게 마이그레이션합니다',
new_0_6_4_7: 'Bridge profile worker endpoint 를 broker endpoint 별로 분리해 같은 Profile 의 운영/preview 가 worker socket 을 서로 빼앗아 unknown run 오류를 만드는 문제를 방지합니다',
new_0_6_3_1: 'Bridge spinner 상태를 더 이상 모델 reasoning 으로 저장하지 않아 장식용 thinking 텍스트가 이후 컨텍스트를 오염시키지 않습니다', new_0_6_3_1: 'Bridge spinner 상태를 더 이상 모델 reasoning 으로 저장하지 않아 장식용 thinking 텍스트가 이후 컨텍스트를 오염시키지 않습니다',
new_0_6_3_2: 'History 에 Hermes CLI 세션을 Web UI 로컬 기록으로 가져오는 컨트롤을 추가하고 메시지 구조를 더 안전하게 정규화합니다', new_0_6_3_2: 'History 에 Hermes CLI 세션을 Web UI 로컬 기록으로 가져오는 컨트롤을 추가하고 메시지 구조를 더 안전하게 정규화합니다',
new_0_6_3_3: 'Provider 설정에서 기본 base URL 편집을 지원하고 LM Studio 를 내장 Provider 로 추가했으며 LM Studio /models 실시간 검색을 지원합니다', new_0_6_3_3: 'Provider 설정에서 기본 base URL 편집을 지원하고 LM Studio 를 내장 Provider 로 추가했으며 LM Studio /models 실시간 검색을 지원합니다',
+7
View File
@@ -1106,6 +1106,13 @@ jobTriggered: 'Job acionado',
// Registro de alteracoes // Registro de alteracoes
changelog: { changelog: {
new_0_6_4_1: 'CI foi reforçado com comportamento npm install fixo e cobertura Docker smoke para PRs',
new_0_6_4_2: 'O chat agora usa paginação virtualizada para rolar e carregar conversas longas com mais estabilidade',
new_0_6_4_3: 'A publicação de imagens Docker agora roda apenas em releases, não em checks comuns de PR',
new_0_6_4_4: 'Version Preview está disponível para super admins, com seleção main/tag, checkout de preview, instalação de dependências, start/stop e logs',
new_0_6_4_5: 'Instâncias preview isolam portas frontend/backend, Web UI home e agent bridge endpoint, com patches runtime para tags antigos cobrindo portas, WebSocket, base URL e navegação preview aninhada',
new_0_6_4_6: 'Tabelas legacy session_usage sem created_at agora migram com segurança usando um valor padrão',
new_0_6_4_7: 'Endpoints bridge profile worker agora são separados por broker endpoint, evitando que produção e preview com o mesmo Profile disputem worker sockets e causem erros unknown run',
new_0_6_3_1: 'O status do spinner do Bridge nao e mais salvo como reasoning do modelo, evitando que texto thinking decorativo contamine o contexto futuro', new_0_6_3_1: 'O status do spinner do Bridge nao e mais salvo como reasoning do modelo, evitando que texto thinking decorativo contamine o contexto futuro',
new_0_6_3_2: 'History agora inclui controles para importar sessoes Hermes CLI para o historico local da Web UI com normalizacao de mensagens mais segura', new_0_6_3_2: 'History agora inclui controles para importar sessoes Hermes CLI para o historico local da Web UI com normalizacao de mensagens mais segura',
new_0_6_3_3: 'A configuracao de Provider suporta base URLs integradas editaveis, LM Studio como provider integrado e descoberta ao vivo via LM Studio /models', new_0_6_3_3: 'A configuracao de Provider suporta base URLs integradas editaveis, LM Studio como provider integrado e descoberta ao vivo via LM Studio /models',
@@ -1333,6 +1333,13 @@ export default {
// 更新日誌 // 更新日誌
changelog: { changelog: {
new_0_6_4_1: 'CI 流程加固:PR 檢查固定 npm 安裝路徑,並補齊 Docker smoke 校驗',
new_0_6_4_2: '聊天訊息列表新增虛擬分頁,長工作階段捲動與載入更穩定',
new_0_6_4_3: 'Docker 映像發布改為僅在 release 場景執行,避免一般 PR 觸發發布流程',
new_0_6_4_4: '新增版本預覽工作流:超級管理員可選擇 main 或 GitHub tag,準備預覽程式碼、安裝依賴、啟動/停止預覽並查看日誌',
new_0_6_4_5: '預覽實例隔離前後端連接埠、Web UI home 與 agent bridge endpoint,並在執行時修補舊版本的連接埠、WebSocket、base URL 與巢狀預覽入口',
new_0_6_4_6: '修復 legacy session_usage 表缺少 created_at 時的遷移問題,舊資料會以預設值補齊',
new_0_6_4_7: '預覽與正式環境的 bridge profile worker endpoint 會依 broker 隔離,避免同名 Profile 並發聊天時互相搶占導致 unknown run',
new_0_6_3_1: 'Bridge spinner 狀態不再寫入模型 reasoning,避免裝飾性 thinking 文字污染後續上下文', new_0_6_3_1: 'Bridge spinner 狀態不再寫入模型 reasoning,避免裝飾性 thinking 文字污染後續上下文',
new_0_6_3_2: 'History 新增 Hermes CLI 工作階段匯入控制,並在匯入時更安全地規範化訊息結構', new_0_6_3_2: 'History 新增 Hermes CLI 工作階段匯入控制,並在匯入時更安全地規範化訊息結構',
new_0_6_3_3: 'Provider 設定支援編輯內建 base URL,新增 LM Studio 內建 Provider,並支援從 LM Studio /models 即時發現模型', new_0_6_3_3: 'Provider 設定支援編輯內建 base URL,新增 LM Studio 內建 Provider,並支援從 LM Studio /models 即時發現模型',
+7
View File
@@ -1330,6 +1330,13 @@ export default {
// 更新日志 // 更新日志
changelog: { changelog: {
new_0_6_4_1: 'CI 流程加固:PR 检查固定 npm 安装路径,并补齐 Docker smoke 校验',
new_0_6_4_2: '聊天消息列表新增虚拟分页,长会话滚动和加载更稳定',
new_0_6_4_3: 'Docker 镜像发布改为仅在 release 场景执行,避免普通 PR 触发发布流程',
new_0_6_4_4: '新增版本预览工作流:超级管理员可选择 main 或 GitHub tag,准备预览代码、安装依赖、启动/停止预览并查看日志',
new_0_6_4_5: '预览实例隔离前后端端口、Web UI home 和 agent bridge endpoint,并在运行时修补旧版本的端口、WebSocket、base URL 与嵌套预览入口',
new_0_6_4_6: '修复 legacy session_usage 表缺少 created_at 时的迁移问题,旧数据会以默认值补齐',
new_0_6_4_7: '预览和正式环境的 bridge profile worker endpoint 按 broker 隔离,避免同名 Profile 并发聊天时互相抢占导致 unknown run',
new_0_6_3_1: 'Bridge spinner 状态不再写入模型 reasoning,避免装饰性 thinking 文案污染后续上下文', new_0_6_3_1: 'Bridge spinner 状态不再写入模型 reasoning,避免装饰性 thinking 文案污染后续上下文',
new_0_6_3_2: 'History 新增 Hermes CLI 会话导入控制,并在导入时更安全地规范化消息结构', new_0_6_3_2: 'History 新增 Hermes CLI 会话导入控制,并在导入时更安全地规范化消息结构',
new_0_6_3_3: 'Provider 配置支持编辑内置 base URL,新增 LM Studio 内置 Provider,并支持从 LM Studio /models 实时发现模型', new_0_6_3_3: 'Provider 配置支持编辑内置 base URL,新增 LM Studio 内置 Provider,并支持从 LM Studio /models 实时发现模型',
+109 -13
View File
@@ -141,6 +141,16 @@ function getNpmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm' return process.platform === 'win32' ? 'npm.cmd' : 'npm'
} }
function isTermuxRuntime() {
const prefix = process.env.PREFIX || ''
return prefix.includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
}
function getPreviewViteHostArg() {
return isTermuxRuntime() ? '127.0.0.1' : ''
}
function getGlobalPackageBin(root: string) { function getGlobalPackageBin(root: string) {
return join(root, 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs') return join(root, 'hermes-web-ui', 'bin', 'hermes-web-ui.mjs')
} }
@@ -261,7 +271,8 @@ function getPreviewStatus() {
const exists = existsSync(previewDir) const exists = existsSync(previewDir)
const hasPackage = existsSync(packagePath) const hasPackage = existsSync(packagePath)
const installed = hasPackage && getMissingPreviewDependencyBins().length === 0 const installed = hasPackage && getMissingPreviewDependencyBins().length === 0
const running = Boolean(previewProcess?.pid && !previewProcess.killed) const runtimePids = getPreviewListeningPids()
const running = Boolean(previewProcess?.pid && !previewProcess.killed) || runtimePids.length > 0
const currentTag = getCurrentPreviewTag() const currentTag = getCurrentPreviewTag()
return { return {
@@ -270,7 +281,7 @@ function getPreviewStatus() {
has_package: hasPackage, has_package: hasPackage,
installed, installed,
running, running,
pid: running ? previewProcess?.pid : null, pid: running ? previewProcess?.pid || runtimePids[0] || null : null,
current_tag: currentTag, current_tag: currentTag,
frontend_url: PREVIEW_FRONTEND_URL, frontend_url: PREVIEW_FRONTEND_URL,
agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(), agent_bridge_endpoint: getPreviewAgentBridgeEndpoint(),
@@ -296,6 +307,65 @@ function isPortAvailable(port: number): Promise<boolean> {
}) })
} }
function parsePidLines(output: string): number[] {
return [...new Set(output
.split(/\r?\n/)
.map(line => Number(line.trim()))
.filter(pid => Number.isFinite(pid) && pid > 0))]
}
function getPreviewListeningPids(): number[] {
const ports = [
PREVIEW_BACKEND_PORT,
PREVIEW_FRONTEND_PORT,
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
]
const pids = new Set<number>()
if (process.platform === 'win32') {
try {
const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { encoding: 'utf-8', windowsHide: true })
for (const line of output.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 5) continue
const [proto, localAddress, , state, pidRaw] = parts
if (proto.toUpperCase() !== 'TCP' || state.toUpperCase() !== 'LISTENING') continue
const listenPort = Number(localAddress.split(':').pop())
if (!ports.includes(listenPort)) continue
const pid = Number(pidRaw)
if (Number.isFinite(pid) && pid > 0) pids.add(pid)
}
} catch {}
return [...pids]
}
for (const port of ports) {
try {
for (const pid of parsePidLines(execFileSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}))) {
pids.add(pid)
}
} catch {}
}
return [...pids]
}
function getUnixProcessGroupId(pid: number): number | null {
try {
const output = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
const pgid = Number(output)
return Number.isFinite(pgid) && pgid > 0 ? pgid : null
} catch {
return null
}
}
async function assertPreviewPortsAvailable() { async function assertPreviewPortsAvailable() {
const ports = [ const ports = [
PREVIEW_BACKEND_PORT, PREVIEW_BACKEND_PORT,
@@ -343,30 +413,52 @@ function openPreviewLogFile() {
async function stopPreviewProcess() { async function stopPreviewProcess() {
const child = previewProcess const child = previewProcess
if (!child?.pid || child.killed) { const pids = new Set<number>()
if (child?.pid && !child.killed) pids.add(child.pid)
for (const pid of getPreviewListeningPids()) pids.add(pid)
if (!pids.size) {
previewProcess = null previewProcess = null
return return
} }
appendPreviewActionLog(`stopping preview process pid=${child.pid}`) appendPreviewActionLog(`stopping preview process pid(s)=${[...pids].join(', ')}`)
try { if (process.platform === 'win32') {
if (process.platform === 'win32') { for (const pid of pids) {
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
} else {
try { try {
process.kill(-child.pid, 'SIGTERM') execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
} catch {}
}
} else {
const pgids = new Set<number>()
for (const pid of pids) {
const pgid = getUnixProcessGroupId(pid)
if (pgid) pgids.add(pgid)
else pgids.add(pid)
}
for (const pgid of pgids) {
try {
process.kill(-pgid, 'SIGTERM')
} catch { } catch {
child.kill('SIGTERM') try { process.kill(pgid, 'SIGTERM') } catch {}
} }
} }
} catch { await sleep(800)
child.kill() const remainingPids = getPreviewListeningPids()
const remainingPgids = new Set(remainingPids.map(getUnixProcessGroupId).filter((pgid): pgid is number => Boolean(pgid)))
for (const pgid of remainingPgids) {
try { process.kill(-pgid, 'SIGKILL') } catch {}
}
} }
previewProcess = null previewProcess = null
await sleep(800) await sleep(800)
} }
export async function stopPreviewRuntime(): Promise<void> {
await stopPreviewProcess()
}
function assertPreviewPackage() { function assertPreviewPackage() {
const packagePath = getPreviewPackagePath() const packagePath = getPreviewPackagePath()
if (!existsSync(packagePath)) { if (!existsSync(packagePath)) {
@@ -482,9 +574,12 @@ function applyPreviewRuntimePatch() {
if (existsSync(packagePath)) { if (existsSync(packagePath)) {
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8')) const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
const hostArg = getPreviewViteHostArg()
pkg.scripts = { pkg.scripts = {
...pkg.scripts, ...pkg.scripts,
'dev:client': `vite --host --port ${PREVIEW_FRONTEND_PORT} --strictPort`, 'dev:client': hostArg
? `vite --host ${hostArg} --port ${PREVIEW_FRONTEND_PORT} --strictPort`
: `vite --host --port ${PREVIEW_FRONTEND_PORT} --strictPort`,
} }
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8')
} }
@@ -892,6 +987,7 @@ export async function startPreview(ctx: any) {
ctx.body = previewPayload({ success: true, message: 'Preview started' }) ctx.body = previewPayload({ success: true, message: 'Preview started' })
} catch (err: any) { } catch (err: any) {
appendPreviewActionLog(`npm run dev failed: ${err.stderr?.toString() || err.message || String(err)}`) appendPreviewActionLog(`npm run dev failed: ${err.stderr?.toString() || err.message || String(err)}`)
await stopPreviewProcess()
ctx.status = 500 ctx.status = 500
ctx.body = previewPayload({ success: false, message: err.message || String(err) }) ctx.body = previewPayload({ success: false, message: err.message || String(err) })
} }
@@ -2413,8 +2413,9 @@ class WorkerProcess:
return _send_bridge_request(self.endpoint, req, request_timeout) return _send_bridge_request(self.endpoint, req, request_timeout)
def _worker_endpoint(key: str) -> str: def _worker_endpoint(key: str, namespace: str | None = None) -> str:
safe = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] namespace_key = f"{namespace or ''}\0{key}"
safe = hashlib.sha256(namespace_key.encode("utf-8")).hexdigest()[:16]
if os.name == "nt": if os.name == "nt":
port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780")) port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780"))
return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}" return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}"
@@ -2637,7 +2638,7 @@ class BridgeBroker:
with self._lock: with self._lock:
worker = self._workers.get(key) worker = self._workers.get(key)
if worker is None: if worker is None:
worker = WorkerProcess(key, profile, _worker_endpoint(key), self.agent_root, self.hermes_home) worker = WorkerProcess(key, profile, _worker_endpoint(key, self.endpoint), self.agent_root, self.hermes_home)
self._workers[key] = worker self._workers[key] = worker
return worker return worker
+8
View File
@@ -1,5 +1,6 @@
import { logger } from './logger' import { logger } from './logger'
import { closeDb } from '../db' import { closeDb } from '../db'
import { stopPreviewRuntime } from '../controllers/update'
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void { export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
let isShuttingDown = false let isShuttingDown = false
@@ -15,6 +16,13 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
console.log(`[shutdown] Received signal: ${signal}`) console.log(`[shutdown] Received signal: ${signal}`)
try { try {
try {
await stopPreviewRuntime()
logger.info('Preview runtime stopped')
} catch (err) {
logger.warn(err, 'Failed to stop preview runtime (non-fatal)')
}
if (agentBridgeManager) { if (agentBridgeManager) {
try { try {
await agentBridgeManager.stop() await agentBridgeManager.stop()
@@ -453,6 +453,23 @@ assert "compress-temp" not in broker._session_worker_key
`) `)
}) })
it('namespaces profile worker endpoints by broker endpoint', () => {
runPython(String.raw`
${harness}
prod_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
preview_endpoint = bridge._worker_endpoint("default", "ipc:///tmp/hermes-web-ui-preview/agent-bridge.sock")
assert prod_endpoint != preview_endpoint
assert prod_endpoint == bridge._worker_endpoint("default", "ipc:///tmp/hermes-agent-bridge.sock")
prod_broker = bridge.BridgeBroker("ipc:///tmp/hermes-agent-bridge.sock")
preview_broker = bridge.BridgeBroker("ipc:///tmp/hermes-web-ui-preview/agent-bridge.sock")
prod_worker = prod_broker._worker_for_profile("default")
preview_worker = preview_broker._worker_for_profile("default")
assert prod_worker.endpoint != preview_worker.endpoint
`)
})
it('restores approval env and clears handlers when a run fails', () => { it('restores approval env and clears handlers when a run fails', () => {
runPython(String.raw` runPython(String.raw`
${harness} ${harness}