fix Windows desktop startup readiness (#1167)
* fix desktop startup readiness on windows * add manual desktop build workflow * hide Windows desktop server process window * hide Windows Python bridge worker windows * use no-window Python for Windows desktop CLI calls --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
name: Manual Desktop Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_os:
|
||||
description: "Desktop target OS"
|
||||
required: true
|
||||
type: choice
|
||||
default: win32
|
||||
options:
|
||||
- win32
|
||||
- darwin
|
||||
- linux
|
||||
target_arch:
|
||||
description: "Desktop target architecture"
|
||||
required: true
|
||||
type: choice
|
||||
default: x64
|
||||
options:
|
||||
- x64
|
||||
- arm64
|
||||
release_tag:
|
||||
description: "Optional release tag to attach artifacts to"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-manual-${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
label: ${{ steps.target.outputs.label }}
|
||||
runner: ${{ steps.target.outputs.runner }}
|
||||
target_os: ${{ steps.target.outputs.target_os }}
|
||||
target_arch: ${{ steps.target.outputs.target_arch }}
|
||||
electron_target: ${{ steps.target.outputs.electron_target }}
|
||||
artifact_name: ${{ steps.target.outputs.artifact_name }}
|
||||
artifact_files: ${{ steps.target.outputs.artifact_files }}
|
||||
steps:
|
||||
- name: Select requested target
|
||||
id: target
|
||||
shell: bash
|
||||
run: |
|
||||
write_common_outputs() {
|
||||
{
|
||||
echo "label=$1"
|
||||
echo "runner=$2"
|
||||
echo "target_os=${{ github.event.inputs.target_os }}"
|
||||
echo "target_arch=${{ github.event.inputs.target_arch }}"
|
||||
echo "electron_target=$3"
|
||||
echo "artifact_name=$4"
|
||||
echo "artifact_files<<EOF"
|
||||
shift 4
|
||||
printf '%s\n' "$@"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
case "${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}" in
|
||||
win32-x64)
|
||||
write_common_outputs "Windows x64" "windows-latest" "--win nsis --x64" "desktop-win32-x64" \
|
||||
"packages/desktop/release/*.exe" \
|
||||
"packages/desktop/release/*.exe.blockmap" \
|
||||
"packages/desktop/release/latest*.yml"
|
||||
;;
|
||||
darwin-arm64)
|
||||
write_common_outputs "macOS arm64" "macos-14" "--mac dmg --arm64" "desktop-darwin-arm64" \
|
||||
"packages/desktop/release/*.dmg" \
|
||||
"packages/desktop/release/*.dmg.blockmap" \
|
||||
"packages/desktop/release/latest*.yml"
|
||||
;;
|
||||
darwin-x64)
|
||||
write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg --x64" "desktop-darwin-x64" \
|
||||
"packages/desktop/release/*.dmg" \
|
||||
"packages/desktop/release/*.dmg.blockmap" \
|
||||
"packages/desktop/release/latest*.yml"
|
||||
;;
|
||||
linux-x64)
|
||||
write_common_outputs "Linux x64" "ubuntu-22.04" "--linux AppImage deb --x64" "desktop-linux-x64" \
|
||||
"packages/desktop/release/*.AppImage" \
|
||||
"packages/desktop/release/*.deb" \
|
||||
"packages/desktop/release/latest*.yml"
|
||||
;;
|
||||
linux-arm64)
|
||||
write_common_outputs "Linux arm64" "ubuntu-22.04-arm" "--linux AppImage --arm64" "desktop-linux-arm64" \
|
||||
"packages/desktop/release/*.AppImage" \
|
||||
"packages/desktop/release/latest*.yml"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported desktop target: ${{ github.event.inputs.target_os }} ${{ github.event.inputs.target_arch }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
desktop:
|
||||
name: Desktop (${{ needs.validate.outputs.label }})
|
||||
needs: validate
|
||||
runs-on: ${{ needs.validate.outputs.runner }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
packages/desktop/package-lock.json
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Install web UI dependencies
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm rebuild node-pty
|
||||
|
||||
- name: Build web UI
|
||||
run: npm run build
|
||||
|
||||
- name: Keep production web UI dependencies only
|
||||
run: npm prune --omit=dev --no-audit --no-fund
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||
|
||||
- name: Prepare bundled Python
|
||||
env:
|
||||
TARGET_OS: ${{ needs.validate.outputs.target_os }}
|
||||
TARGET_ARCH: ${{ needs.validate.outputs.target_arch }}
|
||||
run: npm --prefix packages/desktop run prepare:python
|
||||
|
||||
- name: Configure macOS signing
|
||||
if: needs.validate.outputs.target_os == 'darwin'
|
||||
shell: bash
|
||||
env:
|
||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
MAC_APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
write_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [ -n "$value" ]; then
|
||||
{
|
||||
echo "$name<<EOF"
|
||||
echo "$value"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "${MAC_CSC_LINK:-}" ]; then
|
||||
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
|
||||
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
|
||||
echo "No macOS signing certificate configured; building unsigned and skipping notarization."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_env "CSC_LINK" "$MAC_CSC_LINK"
|
||||
write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD"
|
||||
|
||||
if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then
|
||||
write_env "APPLE_ID" "$MAC_APPLE_ID"
|
||||
write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD"
|
||||
write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID"
|
||||
echo "macOS signing and notarization are configured."
|
||||
else
|
||||
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
|
||||
echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization."
|
||||
fi
|
||||
|
||||
- name: Build desktop artifact
|
||||
shell: bash
|
||||
run: npm --prefix packages/desktop run dist -- ${{ needs.validate.outputs.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ needs.validate.outputs.artifact_name }}
|
||||
path: ${{ needs.validate.outputs.artifact_files }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts to release
|
||||
if: github.event.inputs.release_tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.release_tag }}
|
||||
fail_on_unmatched_files: true
|
||||
files: ${{ needs.validate.outputs.artifact_files }}
|
||||
@@ -9,12 +9,23 @@ import { app } from 'electron'
|
||||
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
|
||||
|
||||
const DEFAULT_PORT = 8748
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let serverProc: ChildProcess | null = null
|
||||
let cachedToken: string | null = null
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function readyTimeoutMs(): number {
|
||||
return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function ensureToken(): string {
|
||||
if (cachedToken) return cachedToken
|
||||
const file = tokenFile()
|
||||
@@ -199,6 +210,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
const bundledPython = isWin
|
||||
? join(pythonDir(), 'python.exe')
|
||||
: join(pythonDir(), 'bin', 'python3')
|
||||
const bundledPythonNoWindow = isWin
|
||||
? join(pythonDir(), 'pythonw.exe')
|
||||
: bundledPython
|
||||
const bridgePort = await getFreeTcpPort()
|
||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||
const loginShellPath = await getLoginShellPath()
|
||||
@@ -219,6 +233,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_BIN: hermesBin(),
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||
HERMES_AGENT_CLI_PYTHON: existsSync(bundledPythonNoWindow) ? bundledPythonNoWindow : bundledPython,
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
||||
@@ -256,6 +271,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
serverProc = spawn(process.execPath, [entry], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
||||
@@ -272,7 +288,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
}
|
||||
})
|
||||
|
||||
await waitForReady(port, READY_TIMEOUT_MS)
|
||||
await waitForReady(port, readyTimeoutMs())
|
||||
return getServerUrl(port)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
|
||||
import { setChatRunServer } from './routes/hermes/chat-run'
|
||||
import { GroupChatServer } from './services/hermes/group-chat'
|
||||
import { ChatRunSocket } from './services/hermes/run-chat'
|
||||
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { getAgentBridgeManager, startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { HermesSkillInjector } from './services/hermes/skill-injector'
|
||||
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
|
||||
import { logger } from './services/logger'
|
||||
@@ -81,6 +81,28 @@ function safeNetworkInterfaces() {
|
||||
}
|
||||
}
|
||||
|
||||
function startRuntimeServicesAfterListen(): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureProfileGatewaysRunning()
|
||||
console.log('[bootstrap] profile gateways checked')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
|
||||
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
})()
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
console.log('[bootstrap] agent bridge started')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] agent bridge failed to start')
|
||||
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
export async function bootstrap() {
|
||||
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
@@ -109,23 +131,7 @@ export async function bootstrap() {
|
||||
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureProfileGatewaysRunning()
|
||||
console.log('[bootstrap] profile gateways checked')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
|
||||
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
const app = new Koa()
|
||||
|
||||
try {
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
console.log('[bootstrap] agent bridge started')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] agent bridge failed to start')
|
||||
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
// Initialize all web-ui SQLite tables
|
||||
const { initAllStores } = await import('./db/hermes/init')
|
||||
@@ -201,6 +207,9 @@ export async function bootstrap() {
|
||||
console.log(`Log: ${config.appHome}/logs/server.log`)
|
||||
logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
|
||||
|
||||
agentBridgeManager = getAgentBridgeManager()
|
||||
startRuntimeServicesAfterListen()
|
||||
|
||||
// Restore group chat agents after server is ready.
|
||||
groupChatServer.restoreWhenReady()
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ def _positive_int(value: str | None) -> int | None:
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _hidden_subprocess_kwargs() -> dict[str, int]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
|
||||
|
||||
|
||||
def _process_exists(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
@@ -76,6 +82,7 @@ def _process_exists(pid: int) -> bool:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
**_hidden_subprocess_kwargs(),
|
||||
)
|
||||
return str(pid) in (result.stdout or "")
|
||||
except Exception:
|
||||
@@ -2785,6 +2792,7 @@ class WorkerProcess:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
**_hidden_subprocess_kwargs(),
|
||||
)
|
||||
self._pipe_stderr()
|
||||
self._wait_ready()
|
||||
@@ -2957,6 +2965,7 @@ def _windows_listening_pids_on_port(port: int) -> list[int]:
|
||||
encoding=_platform_text_encoding(),
|
||||
errors="ignore",
|
||||
timeout=5,
|
||||
**_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -2999,6 +3008,7 @@ def _kill_windows_endpoint_occupants(endpoint: str) -> None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
**_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
|
||||
@@ -52,10 +52,11 @@ export function shouldUseManagedGatewayRun(): boolean {
|
||||
process.platform === 'win32'
|
||||
}
|
||||
|
||||
export function shouldUseManagedGatewayRunForAutostart(): boolean {
|
||||
export function shouldUseManagedGatewayRunForAutostart(platform: NodeJS.Platform = process.platform): boolean {
|
||||
return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') ||
|
||||
isDockerRuntime() ||
|
||||
isTermuxRuntime()
|
||||
isTermuxRuntime() ||
|
||||
platform === 'win32'
|
||||
}
|
||||
|
||||
export function gatewayStatusLooksRunning(output: string): boolean {
|
||||
|
||||
@@ -17,18 +17,20 @@ export function resolveHermesBin(customBin?: string): string {
|
||||
return customBin?.trim() || process.env.HERMES_BIN?.trim() || 'hermes'
|
||||
}
|
||||
|
||||
function bundledPythonForWindows(hermesBin: string): string | null {
|
||||
const envPython = process.env.HERMES_AGENT_BRIDGE_PYTHON?.trim()
|
||||
function bundledCliPythonForWindows(hermesBin: string): string | null {
|
||||
const envPython = process.env.HERMES_AGENT_CLI_PYTHON?.trim()
|
||||
if (envPython) return envPython
|
||||
|
||||
if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null
|
||||
const pythonw = resolve(dirname(hermesBin), '..', 'pythonw.exe')
|
||||
if (existsSync(pythonw)) return pythonw
|
||||
const python = resolve(dirname(hermesBin), '..', 'python.exe')
|
||||
return existsSync(python) ? python : null
|
||||
}
|
||||
|
||||
export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation {
|
||||
if (process.platform === 'win32') {
|
||||
const python = bundledPythonForWindows(hermesBin)
|
||||
const python = bundledCliPythonForWindows(hermesBin)
|
||||
if (python) return { command: python, argsPrefix: ['-m', 'hermes_cli.main'] }
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ describe('gateway autostart status parsing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses managed gateway autostart on Windows', () => {
|
||||
expect(shouldUseManagedGatewayRunForAutostart('win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects managed gateway state files with a live pid', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-'))
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,7 @@ function setPlatform(platform: NodeJS.Platform): void {
|
||||
afterEach(() => {
|
||||
execFileCalls.length = 0
|
||||
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
delete process.env.HERMES_AGENT_CLI_PYTHON
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
vi.resetModules()
|
||||
})
|
||||
@@ -29,7 +30,7 @@ afterEach(() => {
|
||||
describe('Hermes process invocation', () => {
|
||||
it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => {
|
||||
setPlatform('win32')
|
||||
process.env.HERMES_AGENT_BRIDGE_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe'
|
||||
process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\pythonw.exe'
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
const result = await execHermesWithBin(
|
||||
@@ -40,25 +41,26 @@ describe('Hermes process invocation', () => {
|
||||
|
||||
expect(result.stdout).toBe('ok\n')
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: process.env.HERMES_AGENT_BRIDGE_PYTHON,
|
||||
command: process.env.HERMES_AGENT_CLI_PYTHON,
|
||||
args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'],
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => {
|
||||
it('prefers sibling pythonw.exe for a Windows hermes.exe launcher', async () => {
|
||||
setPlatform('win32')
|
||||
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
|
||||
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
|
||||
try {
|
||||
const scripts = join(root, 'Scripts')
|
||||
mkdirSync(scripts)
|
||||
writeFileSync(join(root, 'python.exe'), '')
|
||||
writeFileSync(join(root, 'pythonw.exe'), '')
|
||||
writeFileSync(join(scripts, 'hermes.exe'), '')
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'], { windowsHide: true })
|
||||
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: join(root, 'python.exe'),
|
||||
command: join(root, 'pythonw.exe'),
|
||||
args: ['-m', 'hermes_cli.main', '--version'],
|
||||
})
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user