mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 05:55:28 +08:00
fix(openclaw): fix gateway status detection and improve error reporting (#13433)
### What this PR does Before this PR: - `getStatus` only probed health when status was `stopped` or `error`. If the gateway crashed while status was `running`, the UI would never detect the crash and keep showing "运行中" (running). - `checkHealth` probed the gateway but never updated `gatewayStatus`, so a failed health check had no lasting effect. - `startAndWaitForGateway` spawned the process with `stdio: 'ignore'`, discarding stderr. On startup failure, the user only saw "gateway exited with code 1" with no actionable detail. - `parseUpdateStatus` matched any update channel (npm, pkg, binary), causing false positives for binary-installed users when only an npm/pkg update was available. After this PR: - `getStatus` probes health in all non-`starting` states, transitioning `running → stopped` when the gateway is unreachable. - `checkHealth` syncs `gatewayStatus` to `stopped` when the probe returns unhealthy. - `startAndWaitForGateway` pipes stderr and extracts the first 3 lines of error output for meaningful error messages. - `parseUpdateStatus` only matches binary-channel updates, ignoring npm/pkg channels. - 29 new state-machine tests cover all gateway status transitions and lifecycle scenarios. Fixes # ### Why we need it and why it was done in this way The gateway status was a one-way latch: once set to `running`, it could only be changed by an explicit `stopGateway` call. External crashes (process killed, config errors, port conflicts) left the UI in an incorrect state. The following tradeoffs were made: - `getStatus` now always calls `probeGatewayHealth` (except during `starting`). This adds a small overhead per status poll, but is necessary for correctness since the gateway process lifecycle is independent (detached). - stderr capture uses `['ignore', 'ignore', 'pipe']` instead of full `'pipe'` to minimize resource usage — we only need stderr for error diagnostics. The following alternatives were considered: - Keeping a persistent health-check interval timer — rejected as over-engineered for the current polling-based UI. - Parsing the full `openclaw update status` table structure — rejected in favor of simple regex matching on the `binary` channel keyword. ### Breaking changes None. ### Special notes for your reviewer - The `parseUpdateStatus` change means users who installed OpenClaw via npm will no longer see update notifications in Cherry Studio. This is intentional — Cherry Studio only manages binary installations. - The test file grew significantly (from 90 lines to 595 lines) because this is the first time the `OpenClawService` class has state-machine test coverage. ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note fix(openclaw): gateway status now correctly detects crashed/externally-stopped gateways; startup errors show detailed messages instead of generic exit codes; update checker only reports binary-channel updates ``` --------- Signed-off-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
@@ -417,7 +417,6 @@ export enum IpcChannel {
|
||||
OpenClaw_InstallProgress = 'openclaw:install-progress',
|
||||
OpenClaw_StartGateway = 'openclaw:start-gateway',
|
||||
OpenClaw_StopGateway = 'openclaw:stop-gateway',
|
||||
OpenClaw_RestartGateway = 'openclaw:restart-gateway',
|
||||
OpenClaw_GetStatus = 'openclaw:get-status',
|
||||
OpenClaw_CheckHealth = 'openclaw:check-health',
|
||||
OpenClaw_GetDashboardUrl = 'openclaw:get-dashboard-url',
|
||||
|
||||
@@ -1163,7 +1163,6 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_Uninstall, openClawService.uninstall)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_StartGateway, openClawService.startGateway)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_StopGateway, openClawService.stopGateway)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_RestartGateway, openClawService.restartGateway)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_GetStatus, openClawService.getStatus)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_CheckHealth, openClawService.checkHealth)
|
||||
ipcMain.handle(IpcChannel.OpenClaw_GetDashboardUrl, openClawService.getDashboardUrl)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync, spawn } from 'node:child_process'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import { Socket } from 'node:net'
|
||||
@@ -21,10 +22,10 @@ import { windowService } from './WindowService'
|
||||
const logger = loggerService.withContext('OpenClawService')
|
||||
|
||||
const OPENCLAW_CONFIG_DIR = path.join(os.homedir(), '.openclaw')
|
||||
// Original user config (read-only, used as template for first-time setup)
|
||||
const OPENCLAW_ORIGINAL_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json')
|
||||
// Cherry Studio's isolated config (read/write) — OpenClaw reads the OPENCLAW_CONFIG_PATH env var to locate this
|
||||
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.cherry.json')
|
||||
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json')
|
||||
const OPENCLAW_CONFIG_BAK_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.json.bak')
|
||||
const OPENCLAW_LEGACY_CONFIG_PATH = path.join(OPENCLAW_CONFIG_DIR, 'openclaw.cherry.json')
|
||||
const SYMLINK_PATH = '/usr/local/bin/openclaw'
|
||||
const DEFAULT_GATEWAY_PORT = 18790
|
||||
|
||||
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error'
|
||||
@@ -125,7 +126,6 @@ class OpenClawService {
|
||||
this.uninstall = this.uninstall.bind(this)
|
||||
this.startGateway = this.startGateway.bind(this)
|
||||
this.stopGateway = this.stopGateway.bind(this)
|
||||
this.restartGateway = this.restartGateway.bind(this)
|
||||
this.getStatus = this.getStatus.bind(this)
|
||||
this.checkHealth = this.checkHealth.bind(this)
|
||||
this.getDashboardUrl = this.getDashboardUrl.bind(this)
|
||||
@@ -172,6 +172,75 @@ class OpenClawService {
|
||||
win?.webContents.send(IpcChannel.OpenClaw_InstallProgress, { message, type })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a symlink in /usr/local/bin (macOS/Linux) or add bin dir to user PATH (Windows).
|
||||
* Removes any existing symlink first to ensure a clean state.
|
||||
*/
|
||||
private async linkBinary(): Promise<void> {
|
||||
const binaryPath = await getBinaryPath('openclaw')
|
||||
if (isWin) {
|
||||
const binDir = await getBinaryPath()
|
||||
try {
|
||||
const regQuery = execSync('reg query "HKCU\\Environment" /v Path', { encoding: 'utf-8' })
|
||||
const currentPath = regQuery.match(/Path\s+REG_\w+\s+(.*)/)?.[1]?.trim() || ''
|
||||
if (!currentPath.split(';').some((p) => p.toLowerCase() === binDir.toLowerCase())) {
|
||||
const newPath = currentPath ? `${currentPath};${binDir}` : binDir
|
||||
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${newPath}" /f`)
|
||||
// Broadcast WM_SETTINGCHANGE so new shells pick up the change
|
||||
execSync('setx OPENCLAW_PATH_REFRESH ""')
|
||||
logger.info(`Added ${binDir} to user PATH`)
|
||||
}
|
||||
} catch {
|
||||
// User PATH key may not exist yet
|
||||
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${binDir}" /f`)
|
||||
logger.info(`Created user PATH with ${binDir}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// Remove existing symlink or file at target path
|
||||
if (fs.existsSync(SYMLINK_PATH)) {
|
||||
fs.unlinkSync(SYMLINK_PATH)
|
||||
}
|
||||
fs.symlinkSync(binaryPath, SYMLINK_PATH)
|
||||
logger.info(`Created symlink: ${SYMLINK_PATH} -> ${binaryPath}`)
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to create symlink at ${SYMLINK_PATH} (may need elevated permissions):`, err as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the symlink from /usr/local/bin (macOS/Linux) or remove bin dir from user PATH (Windows).
|
||||
*/
|
||||
private async unlinkBinary(): Promise<void> {
|
||||
if (isWin) {
|
||||
const binDir = await getBinaryPath()
|
||||
try {
|
||||
const regQuery = execSync('reg query "HKCU\\Environment" /v Path', { encoding: 'utf-8' })
|
||||
const currentPath = regQuery.match(/Path\s+REG_\w+\s+(.*)/)?.[1]?.trim() || ''
|
||||
const parts = currentPath.split(';').filter((p) => p.toLowerCase() !== binDir.toLowerCase())
|
||||
const newPath = parts.join(';')
|
||||
if (newPath) {
|
||||
execSync(`reg add "HKCU\\Environment" /v Path /t REG_EXPAND_SZ /d "${newPath}" /f`)
|
||||
} else {
|
||||
execSync('reg delete "HKCU\\Environment" /v Path /f')
|
||||
}
|
||||
logger.info(`Removed ${binDir} from user PATH`)
|
||||
} catch {
|
||||
logger.debug('No user PATH to clean up')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (fs.existsSync(SYMLINK_PATH)) {
|
||||
fs.unlinkSync(SYMLINK_PATH)
|
||||
logger.info(`Removed symlink: ${SYMLINK_PATH}`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to remove symlink at ${SYMLINK_PATH}:`, err as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OpenClaw by downloading the binary from releases.
|
||||
* Uses gitcode.com mirror for China users, GitHub releases for others.
|
||||
@@ -190,6 +259,8 @@ class OpenClawService {
|
||||
this.sendInstallProgress('Downloading and installing OpenClaw...')
|
||||
await runInstallScript('install-openclaw.js', extraEnv)
|
||||
|
||||
await this.linkBinary()
|
||||
|
||||
this.sendInstallProgress('OpenClaw installed successfully!')
|
||||
logger.info('OpenClaw binary installed via install script')
|
||||
|
||||
@@ -218,6 +289,8 @@ class OpenClawService {
|
||||
|
||||
this.sendInstallProgress('Removing OpenClaw binary...')
|
||||
|
||||
await this.unlinkBinary()
|
||||
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
fs.unlinkSync(binaryPath)
|
||||
logger.info(`Removed OpenClaw binary: ${binaryPath}`)
|
||||
@@ -262,19 +335,25 @@ class OpenClawService {
|
||||
const isPortOpen = await this.checkPortOpen(this.gatewayPort)
|
||||
if (isPortOpen) {
|
||||
// Check if this is our gateway already running on this port
|
||||
const shellEnv = await refreshShellEnv()
|
||||
const openclawPath = await this.findOpenClawBinary()
|
||||
if (openclawPath) {
|
||||
const alreadyRunning = await this.checkGatewayStatus(openclawPath, shellEnv)
|
||||
if (alreadyRunning) {
|
||||
this.gatewayStatus = 'running'
|
||||
logger.info(`Reusing existing gateway on port ${this.gatewayPort}`)
|
||||
return { success: true }
|
||||
const { status } = await this.checkGatewayHealth()
|
||||
if (status === 'healthy') {
|
||||
// Stop the stale gateway (e.g. respawned orphan from a previous session)
|
||||
logger.info('Detected stale gateway on port, stopping before restart...')
|
||||
await this.stopGateway()
|
||||
|
||||
// Verify port is now free
|
||||
const stillOpen = await this.checkPortOpen(this.gatewayPort)
|
||||
if (stillOpen) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Port ${this.gatewayPort} is still in use after stopping the old gateway.`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Port ${this.gatewayPort} is already in use by another application. Please choose a different port.`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Port ${this.gatewayPort} is already in use by another application. Please choose a different port.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,31 +383,41 @@ class OpenClawService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start gateway via `openclaw gateway --force` and wait for it to become ready.
|
||||
* Start gateway via `openclaw gateway run --force` and wait for it to become ready.
|
||||
* Spawns the gateway as a detached process so its lifecycle is independent.
|
||||
* Uses `openclaw gateway stop` to stop it later.
|
||||
* Uses process termination to stop it later.
|
||||
*/
|
||||
private async startAndWaitForGateway(openclawPath: string, shellEnv: Record<string, string>): Promise<void> {
|
||||
const args = ['gateway', '--force']
|
||||
const args = ['gateway', 'run', '--force']
|
||||
|
||||
logger.info(`Starting gateway: ${openclawPath} ${args.join(' ')}`)
|
||||
|
||||
// Spawn detached — the gateway runs independently, we poll for readiness
|
||||
const proc = crossPlatformSpawn(openclawPath, args, {
|
||||
env: { ...shellEnv, OPENCLAW_CONFIG_PATH },
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
// Spawn the gateway process. We poll for readiness via health check.
|
||||
// On Windows, avoid detached: true as it creates a visible console window.
|
||||
// Instead, use windowsHide: true without detached - proc.unref() ensures
|
||||
// the parent can exit independently.
|
||||
const proc = spawn(openclawPath, args, {
|
||||
env: shellEnv,
|
||||
detached: !isWin, // Only detach on non-Windows to avoid console flash
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
proc.unref()
|
||||
|
||||
// Collect early exit errors (e.g. binary crash on startup)
|
||||
let earlyExitError = ''
|
||||
let stderrOutput = ''
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderrOutput += data.toString()
|
||||
})
|
||||
proc.on('error', (err) => {
|
||||
earlyExitError = err.message
|
||||
})
|
||||
proc.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
earlyExitError = `gateway exited with code ${code}`
|
||||
// Extract the most useful line from stderr for the error message
|
||||
const detail = stderrOutput.trim().split('\n').filter(Boolean).slice(0, 3).join('\n')
|
||||
earlyExitError = detail || `gateway exited with code ${code}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -349,7 +438,7 @@ class OpenClawService {
|
||||
}
|
||||
|
||||
logger.debug(`Polling gateway health (attempt ${pollCount})...`)
|
||||
const { status, error: healthError } = await this.probeGatewayHealthWithError(openclawPath, shellEnv)
|
||||
const { status, error: healthError } = await this.checkGatewayHealthWithError()
|
||||
if (status === 'healthy') {
|
||||
logger.info(`Gateway is healthy (verified after ${pollCount} polls)`)
|
||||
return
|
||||
@@ -362,23 +451,17 @@ class OpenClawService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the OpenClaw Gateway
|
||||
* Stop the OpenClaw Gateway.
|
||||
* Kills all openclaw processes to ensure clean shutdown.
|
||||
*/
|
||||
public async stopGateway(): Promise<OperationResult> {
|
||||
try {
|
||||
const openclawPath = await this.findOpenClawBinary()
|
||||
if (!openclawPath) {
|
||||
this.gatewayStatus = 'error'
|
||||
return { success: false, message: 'OpenClaw binary not found' }
|
||||
}
|
||||
this.killAllOpenClawProcesses()
|
||||
|
||||
const shellEnv = await getShellEnv()
|
||||
await this.runGatewayStop(openclawPath, shellEnv)
|
||||
|
||||
const stillRunning = await this.waitForGatewayStop(openclawPath, shellEnv)
|
||||
const stillRunning = await this.waitForGatewayStop()
|
||||
if (stillRunning) {
|
||||
this.gatewayStatus = 'error'
|
||||
return { success: false, message: 'Failed to stop gateway. Try running: openclaw gateway stop' }
|
||||
return { success: false, message: 'Failed to stop gateway' }
|
||||
}
|
||||
|
||||
this.gatewayStatus = 'stopped'
|
||||
@@ -392,18 +475,47 @@ class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all openclaw processes by finding processes on the gateway port.
|
||||
* This works reliably on Windows where process name may show as bun.exe.
|
||||
*/
|
||||
private killAllOpenClawProcesses(): void {
|
||||
const currentPid = process.pid
|
||||
try {
|
||||
if (isWin) {
|
||||
const output = execSync(`netstat -ano | findstr ":${this.gatewayPort}"`, { encoding: 'utf-8' })
|
||||
const pids = new Set<string>()
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/LISTENING\s+(\d+)/)
|
||||
if (match && Number(match[1]) !== currentPid) {
|
||||
pids.add(match[1])
|
||||
}
|
||||
}
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' })
|
||||
logger.info(`Killed process ${pid} on port ${this.gatewayPort}`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execSync('pkill -9 openclaw', { stdio: 'ignore' })
|
||||
logger.info('Killed all openclaw processes')
|
||||
}
|
||||
} catch {
|
||||
logger.debug('No openclaw processes to kill')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for gateway to actually stop, with retries.
|
||||
* Returns true if gateway is still running after all retries.
|
||||
*/
|
||||
private async waitForGatewayStop(
|
||||
openclawPath: string,
|
||||
env: Record<string, string>,
|
||||
maxRetries = 3,
|
||||
intervalMs = 1000
|
||||
): Promise<boolean> {
|
||||
private async waitForGatewayStop(maxRetries = 3, intervalMs = 1000): Promise<boolean> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const stillRunning = await this.checkGatewayStatus(openclawPath, env)
|
||||
const { status } = await this.checkGatewayHealth()
|
||||
const stillRunning = status === 'healthy'
|
||||
if (!stillRunning) {
|
||||
return false
|
||||
}
|
||||
@@ -415,10 +527,6 @@ class OpenClawService {
|
||||
return true
|
||||
}
|
||||
|
||||
private async runGatewayStop(openclawPath: string, env: Record<string, string>): Promise<void> {
|
||||
await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'stop'], env)
|
||||
}
|
||||
|
||||
private async execOpenClawCommandWithResult(
|
||||
openclawPath: string,
|
||||
args: string[],
|
||||
@@ -426,9 +534,7 @@ class OpenClawService {
|
||||
timeoutMs = 20000
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = crossPlatformSpawn(openclawPath, args, {
|
||||
env: { ...env, OPENCLAW_CONFIG_PATH }
|
||||
})
|
||||
const proc = crossPlatformSpawn(openclawPath, args, { env })
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -460,35 +566,23 @@ class OpenClawService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the OpenClaw Gateway
|
||||
*/
|
||||
public async restartGateway(): Promise<OperationResult> {
|
||||
const openclawPath = await this.findOpenClawBinary()
|
||||
if (!openclawPath) {
|
||||
this.gatewayStatus = 'error'
|
||||
return { success: false, message: 'OpenClaw binary not found' }
|
||||
}
|
||||
const shellEnv = await getShellEnv()
|
||||
const { code, stderr } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'restart'], shellEnv)
|
||||
if (code !== 0) {
|
||||
this.gatewayStatus = 'error'
|
||||
return { success: false, message: stderr.trim() || `Restart failed with code ${code}` }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway status. Probes the port when idle to detect externally-started gateways.
|
||||
*/
|
||||
public async getStatus(): Promise<{ status: GatewayStatus; port: number }> {
|
||||
if (this.gatewayStatus === 'stopped' || this.gatewayStatus === 'error') {
|
||||
const { status } = await this.probeGatewayHealth()
|
||||
if (status === 'healthy') {
|
||||
logger.info(`Detected externally running gateway on port ${this.gatewayPort}`)
|
||||
this.gatewayStatus = 'running'
|
||||
}
|
||||
if (this.gatewayStatus === 'starting') {
|
||||
return { status: this.gatewayStatus, port: this.gatewayPort }
|
||||
}
|
||||
|
||||
const { status } = await this.checkGatewayHealth()
|
||||
if (status === 'healthy' && this.gatewayStatus !== 'running') {
|
||||
logger.info(`Detected externally running gateway on port ${this.gatewayPort}`)
|
||||
this.gatewayStatus = 'running'
|
||||
} else if (status === 'unhealthy' && this.gatewayStatus === 'running') {
|
||||
logger.warn(`Gateway on port ${this.gatewayPort} is no longer reachable, marking as stopped`)
|
||||
this.gatewayStatus = 'stopped'
|
||||
}
|
||||
|
||||
return {
|
||||
status: this.gatewayStatus,
|
||||
port: this.gatewayPort
|
||||
@@ -503,24 +597,31 @@ class OpenClawService {
|
||||
if (this.gatewayStatus !== 'running') {
|
||||
return { status: 'unhealthy', gatewayPort: this.gatewayPort }
|
||||
}
|
||||
return this.probeGatewayHealth()
|
||||
const healthInfo = await this.checkGatewayHealth()
|
||||
if (healthInfo.status === 'unhealthy') {
|
||||
logger.warn(`Gateway health check failed, marking as stopped`)
|
||||
this.gatewayStatus = 'stopped'
|
||||
}
|
||||
return healthInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe gateway health by running `openclaw gateway health`.
|
||||
* Probe gateway health via HTTP request to the health endpoint.
|
||||
* This is faster than spawning the openclaw binary.
|
||||
* Expected response: {"ok":true,"status":"live"}
|
||||
* Does NOT check gatewayStatus — callers that need to detect
|
||||
* externally-started gateways should call this directly.
|
||||
*/
|
||||
private async probeGatewayHealth(): Promise<HealthInfo> {
|
||||
private async checkGatewayHealth(): Promise<HealthInfo> {
|
||||
try {
|
||||
const openclawPath = await this.findOpenClawBinary()
|
||||
if (!openclawPath) {
|
||||
return { status: 'unhealthy', gatewayPort: this.gatewayPort }
|
||||
}
|
||||
const shellEnv = await getShellEnv()
|
||||
const { code } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'health'], shellEnv)
|
||||
if (code === 0) {
|
||||
return { status: 'healthy', gatewayPort: this.gatewayPort }
|
||||
const response = await fetch(`http://127.0.0.1:${this.gatewayPort}/health`, {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { ok?: boolean; status?: string }
|
||||
if (data.ok && data.status === 'live') {
|
||||
return { status: 'healthy', gatewayPort: this.gatewayPort }
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Health probe failed:', error as Error)
|
||||
@@ -538,20 +639,23 @@ class OpenClawService {
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
logger.debug(`Port ${port} is open (connected)`)
|
||||
resolve(true)
|
||||
})
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy()
|
||||
logger.debug(`Port ${port} check timed out`)
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
socket.on('error', () => {
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy()
|
||||
logger.debug(`Port ${port} is not open: ${err.message}`)
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
socket.connect(port, 'localhost')
|
||||
socket.connect(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -611,22 +715,24 @@ class OpenClawService {
|
||||
fs.mkdirSync(OPENCLAW_CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// Read existing cherry config, or copy from original openclaw.json as base
|
||||
// Migrate legacy openclaw.cherry.json → openclaw.json
|
||||
if (fs.existsSync(OPENCLAW_LEGACY_CONFIG_PATH)) {
|
||||
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
fs.renameSync(OPENCLAW_CONFIG_PATH, OPENCLAW_CONFIG_BAK_PATH)
|
||||
logger.info('Migrated openclaw.json → openclaw.json.bak')
|
||||
}
|
||||
fs.renameSync(OPENCLAW_LEGACY_CONFIG_PATH, OPENCLAW_CONFIG_PATH)
|
||||
logger.info('Migrated openclaw.cherry.json → openclaw.json')
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
let config: OpenClawConfig = {}
|
||||
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
try {
|
||||
const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8')
|
||||
config = JSON.parse(content)
|
||||
} catch {
|
||||
logger.warn('Failed to parse existing Cherry OpenClaw config, creating new one')
|
||||
}
|
||||
} else if (fs.existsSync(OPENCLAW_ORIGINAL_CONFIG_PATH)) {
|
||||
try {
|
||||
const content = fs.readFileSync(OPENCLAW_ORIGINAL_CONFIG_PATH, 'utf-8')
|
||||
config = JSON.parse(content)
|
||||
logger.info('Using original openclaw.json as base template for openclaw.cherry.json')
|
||||
} catch {
|
||||
logger.warn('Failed to parse original openclaw.json, creating new config')
|
||||
logger.warn('Failed to parse existing OpenClaw config, creating new one')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,65 +920,23 @@ class OpenClawService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check gateway status using `openclaw gateway status` command
|
||||
* Returns true if gateway is running
|
||||
* Like checkGatewayHealth but also returns error message when unhealthy.
|
||||
* Uses HTTP request for faster health checks.
|
||||
* Expected response: {"ok":true,"status":"live"}
|
||||
*/
|
||||
private async checkGatewayStatus(openclawPath: string, env: Record<string, string>): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const statusProcess = crossPlatformSpawn(openclawPath, ['gateway', 'status'], {
|
||||
env: { ...env, OPENCLAW_CONFIG_PATH }
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let resolved = false
|
||||
|
||||
const doResolve = (value: boolean) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
// On timeout, check stdout accumulated so far before giving up
|
||||
const lowerStdout = stdout.toLowerCase()
|
||||
const isRunning = lowerStdout.includes('listening')
|
||||
logger.debug(`Gateway status check timed out after 10s, stdout indicates running: ${isRunning}`)
|
||||
statusProcess.kill('SIGKILL')
|
||||
doResolve(isRunning)
|
||||
}, 10_000)
|
||||
|
||||
statusProcess.stdout?.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
statusProcess.on('close', (code) => {
|
||||
clearTimeout(timeoutId)
|
||||
const lowerStdout = stdout.toLowerCase()
|
||||
const isRunning = (code === 0 || code === null) && lowerStdout.includes('listening')
|
||||
logger.debug('Gateway status check result:', { code, stdout: stdout.trim(), isRunning })
|
||||
doResolve(isRunning)
|
||||
})
|
||||
|
||||
statusProcess.on('error', () => {
|
||||
clearTimeout(timeoutId)
|
||||
doResolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Like probeGatewayHealth but also returns stderr when unhealthy.
|
||||
*/
|
||||
private async probeGatewayHealthWithError(
|
||||
openclawPath: string,
|
||||
env: Record<string, string>
|
||||
): Promise<{ status: 'healthy' | 'unhealthy'; error?: string }> {
|
||||
private async checkGatewayHealthWithError(): Promise<{ status: 'healthy' | 'unhealthy'; error?: string }> {
|
||||
try {
|
||||
const { code, stderr } = await this.execOpenClawCommandWithResult(openclawPath, ['gateway', 'health'], env)
|
||||
if (code === 0) {
|
||||
return { status: 'healthy' }
|
||||
const response = await fetch(`http://127.0.0.1:${this.gatewayPort}/health`, {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { ok?: boolean; status?: string }
|
||||
if (data.ok && data.status === 'live') {
|
||||
return { status: 'healthy' }
|
||||
}
|
||||
return { status: 'unhealthy', error: `Gateway not live: ${JSON.stringify(data)}` }
|
||||
}
|
||||
return { status: 'unhealthy', error: stderr.trim() || undefined }
|
||||
return { status: 'unhealthy', error: `HTTP ${response.status}: ${response.statusText}` }
|
||||
} catch (error) {
|
||||
return { status: 'unhealthy', error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,410 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseCurrentVersion, parseUpdateStatus } from '../utils/openClawParsers'
|
||||
|
||||
// --- Mocks for OpenClawService dependencies ---
|
||||
|
||||
vi.mock('@main/services/WindowService', () => ({
|
||||
windowService: {
|
||||
getMainWindow: vi.fn(() => ({
|
||||
webContents: { send: vi.fn() }
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/process', () => ({
|
||||
crossPlatformSpawn: vi.fn(),
|
||||
findExecutableInEnv: vi.fn(),
|
||||
getBinaryPath: vi.fn(() => Promise.resolve('/mock/bin/openclaw')),
|
||||
runInstallScript: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/shell-env', () => ({
|
||||
default: vi.fn(() => Promise.resolve({ PATH: '/usr/bin' })),
|
||||
refreshShellEnv: vi.fn(() => Promise.resolve({ PATH: '/usr/bin' }))
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/ipService', () => ({
|
||||
isUserInChina: vi.fn(() => Promise.resolve(false))
|
||||
}))
|
||||
|
||||
vi.mock('@main/constant', () => ({
|
||||
isWin: false
|
||||
}))
|
||||
|
||||
vi.mock('@shared/IpcChannel', () => ({
|
||||
IpcChannel: { OpenClaw_InstallProgress: 'openclaw:install-progress' }
|
||||
}))
|
||||
|
||||
vi.mock('@shared/utils', () => ({
|
||||
hasAPIVersion: vi.fn(() => false),
|
||||
withoutTrailingSlash: vi.fn((url: string) => url.replace(/\/+$/, ''))
|
||||
}))
|
||||
|
||||
// openClawParsers: not mocked — tested directly below
|
||||
|
||||
vi.mock('../VertexAIService', () => ({
|
||||
default: { getInstance: vi.fn() }
|
||||
}))
|
||||
|
||||
// --- Import service after mocks are set up ---
|
||||
|
||||
async function createService() {
|
||||
const mod = await import('../OpenClawService')
|
||||
return mod.openClawService
|
||||
}
|
||||
|
||||
describe('OpenClawService gateway status state machine', () => {
|
||||
let service: Awaited<ReturnType<typeof createService>>
|
||||
let checkHealthSpy: ReturnType<typeof vi.spyOn>
|
||||
let findBinarySpy: ReturnType<typeof vi.spyOn>
|
||||
let checkPortOpenSpy: ReturnType<typeof vi.spyOn>
|
||||
let startAndWaitSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
service = await createService()
|
||||
|
||||
// Reset internal state to 'stopped' via reflection
|
||||
// @ts-expect-error -- accessing private field for testing
|
||||
service.gatewayStatus = 'stopped'
|
||||
// @ts-expect-error -- accessing private field for testing
|
||||
service.gatewayPort = 18790
|
||||
// @ts-expect-error -- accessing private field for testing
|
||||
service.gatewayAuthToken = ''
|
||||
|
||||
// Spy on private methods via prototype
|
||||
checkHealthSpy = vi.spyOn(service as any, 'checkGatewayHealth')
|
||||
findBinarySpy = vi.spyOn(service as any, 'findOpenClawBinary')
|
||||
checkPortOpenSpy = vi.spyOn(service as any, 'checkPortOpen')
|
||||
startAndWaitSpy = vi.spyOn(service as any, 'startAndWaitForGateway')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ─── getStatus ───────────────────────────────────────────────
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns "starting" immediately without probing health', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'starting'
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'starting', port: 18790 })
|
||||
expect(checkHealthSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects externally running gateway when stopped', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'stopped'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'running', port: 18790 })
|
||||
})
|
||||
|
||||
it('detects externally running gateway when in error state', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'error'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'running', port: 18790 })
|
||||
})
|
||||
|
||||
it('detects crashed gateway and transitions running → stopped', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'stopped', port: 18790 })
|
||||
})
|
||||
|
||||
it('stays running when health probe is healthy', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'running', port: 18790 })
|
||||
})
|
||||
|
||||
it('stays stopped when health probe is unhealthy', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'stopped'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'stopped', port: 18790 })
|
||||
})
|
||||
|
||||
it('stays in error when health probe is unhealthy', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'error'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.getStatus()
|
||||
|
||||
expect(result).toEqual({ status: 'error', port: 18790 })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── checkHealth ─────────────────────────────────────────────
|
||||
|
||||
describe('checkHealth', () => {
|
||||
it('returns unhealthy immediately when status is stopped', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'stopped'
|
||||
|
||||
const result = await service.checkHealth()
|
||||
|
||||
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
expect(checkHealthSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unhealthy immediately when status is error', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'error'
|
||||
|
||||
const result = await service.checkHealth()
|
||||
|
||||
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
expect(checkHealthSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unhealthy immediately when status is starting', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'starting'
|
||||
|
||||
const result = await service.checkHealth()
|
||||
|
||||
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
expect(checkHealthSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes and returns healthy when gateway is running and reachable', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.checkHealth()
|
||||
|
||||
expect(result).toEqual({ status: 'healthy', gatewayPort: 18790 })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('running')
|
||||
})
|
||||
|
||||
it('transitions running → stopped when probe returns unhealthy', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.checkHealth()
|
||||
|
||||
expect(result).toEqual({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('stopped')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── startGateway ────────────────────────────────────────────
|
||||
|
||||
describe('startGateway', () => {
|
||||
const event = {} as Electron.IpcMainInvokeEvent
|
||||
|
||||
it('rejects concurrent startup calls', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'starting'
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result).toEqual({ success: false, message: 'Gateway is already starting' })
|
||||
})
|
||||
|
||||
it('stops stale gateway and restarts when port is in use by our gateway', async () => {
|
||||
// First call: port occupied; after stop: port free
|
||||
checkPortOpenSpy.mockResolvedValueOnce(true).mockResolvedValue(false)
|
||||
checkHealthSpy
|
||||
.mockResolvedValueOnce({ status: 'healthy', gatewayPort: 18790 }) // startGateway detects our gateway
|
||||
.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 }) // waitForGatewayStop confirms stopped
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockResolvedValue(undefined)
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('running')
|
||||
})
|
||||
|
||||
it('fails when port is in use by another application', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(true)
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect('message' in result && result.message).toContain('already in use')
|
||||
})
|
||||
|
||||
it('fails when binary is not found', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue(null)
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
message: 'OpenClaw binary not found. Please install OpenClaw first.'
|
||||
})
|
||||
})
|
||||
|
||||
it('transitions to running on successful start', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockResolvedValue(undefined)
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('running')
|
||||
})
|
||||
|
||||
it('transitions to error when start fails', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockRejectedValue(new Error('Gateway timeout'))
|
||||
|
||||
const result = await service.startGateway(event)
|
||||
|
||||
expect(result).toEqual({ success: false, message: 'Gateway timeout' })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('error')
|
||||
})
|
||||
|
||||
it('sets status to starting during startup', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
|
||||
let statusDuringStart: string | undefined
|
||||
startAndWaitSpy.mockImplementation(async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
statusDuringStart = service.gatewayStatus
|
||||
})
|
||||
|
||||
await service.startGateway(event)
|
||||
|
||||
expect(statusDuringStart).toBe('starting')
|
||||
})
|
||||
|
||||
it('uses custom port when provided', async () => {
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockResolvedValue(undefined)
|
||||
|
||||
await service.startGateway(event, 9999)
|
||||
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayPort).toBe(9999)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── stopGateway ─────────────────────────────────────────────
|
||||
|
||||
describe('stopGateway', () => {
|
||||
it('transitions to stopped on successful stop', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 }) // gateway stopped
|
||||
|
||||
const result = await service.stopGateway()
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('stopped')
|
||||
})
|
||||
|
||||
it('transitions to error when gateway fails to stop', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 }) // still running
|
||||
|
||||
const result = await service.stopGateway()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Full state transition scenarios ─────────────────────────
|
||||
|
||||
describe('full lifecycle transitions', () => {
|
||||
const event = {} as Electron.IpcMainInvokeEvent
|
||||
|
||||
it('stopped → starting → running → (crash) → stopped', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('stopped')
|
||||
|
||||
// Start
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockResolvedValue(undefined)
|
||||
await service.startGateway(event)
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('running')
|
||||
|
||||
// Gateway crashes externally — getStatus detects it
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
const status = await service.getStatus()
|
||||
expect(status.status).toBe('stopped')
|
||||
})
|
||||
|
||||
it('stopped → starting → error → (external recovery) → running', async () => {
|
||||
// Start fails
|
||||
checkPortOpenSpy.mockResolvedValue(false)
|
||||
findBinarySpy.mockResolvedValue('/mock/bin/openclaw')
|
||||
startAndWaitSpy.mockRejectedValue(new Error('timeout'))
|
||||
await service.startGateway(event)
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('error')
|
||||
|
||||
// External recovery — someone starts gateway manually
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
const status = await service.getStatus()
|
||||
expect(status.status).toBe('running')
|
||||
})
|
||||
|
||||
it('running → checkHealth unhealthy → stopped → getStatus healthy → running', async () => {
|
||||
// @ts-expect-error -- accessing private field
|
||||
service.gatewayStatus = 'running'
|
||||
|
||||
// checkHealth detects crash
|
||||
checkHealthSpy.mockResolvedValue({ status: 'unhealthy', gatewayPort: 18790 })
|
||||
await service.checkHealth()
|
||||
// @ts-expect-error -- accessing private field
|
||||
expect(service.gatewayStatus).toBe('stopped')
|
||||
|
||||
// getStatus detects recovery
|
||||
checkHealthSpy.mockResolvedValue({ status: 'healthy', gatewayPort: 18790 })
|
||||
const status = await service.getStatus()
|
||||
expect(status.status).toBe('running')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Parser tests (preserved from original) ─────────────────────
|
||||
|
||||
describe('parseCurrentVersion', () => {
|
||||
const cases = [
|
||||
{ name: 'standard version output', input: 'OpenClaw 2026.3.9 (fe96034)', expected: '2026.3.9' },
|
||||
@@ -36,22 +439,46 @@ describe('parseCurrentVersion', () => {
|
||||
describe('parseUpdateStatus', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'npm update available',
|
||||
input: 'Update available (npm 2026.3.11). Run: openclaw update',
|
||||
expected: '2026.3.11'
|
||||
name: 'binary update via summary line',
|
||||
input: 'Update available (binary 2026.3.12). Run: openclaw update',
|
||||
expected: '2026.3.12'
|
||||
},
|
||||
{
|
||||
name: 'pkg update available',
|
||||
input: 'Update available · pkg · npm update 2026.3.11',
|
||||
expected: '2026.3.11'
|
||||
name: 'binary update via table row',
|
||||
input: 'available · binary · 2026.3.12',
|
||||
expected: '2026.3.12'
|
||||
},
|
||||
{
|
||||
name: 'update available with semver',
|
||||
input: 'Update available (npm 1.2.3). Run: openclaw update',
|
||||
name: 'binary update with semver',
|
||||
input: 'Update available (binary 1.2.3). Run: openclaw update',
|
||||
expected: '1.2.3'
|
||||
},
|
||||
{
|
||||
name: 'full table output with update',
|
||||
name: 'full table output with binary update',
|
||||
input: [
|
||||
'OpenClaw update status',
|
||||
'┌──────────┬─────────────────────────────────┐',
|
||||
'│ Install │ binary (~/.cherrystudio/bin) │',
|
||||
'│ Channel │ stable (default) │',
|
||||
'│ Update │ available · binary · 2026.3.12 │',
|
||||
'└──────────┴─────────────────────────────────┘',
|
||||
'',
|
||||
'Update available (binary 2026.3.12). Run: openclaw update'
|
||||
].join('\n'),
|
||||
expected: '2026.3.12'
|
||||
},
|
||||
{
|
||||
name: 'ignores npm update (summary)',
|
||||
input: 'Update available (npm 2026.3.11). Run: openclaw update',
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
name: 'ignores pkg update (table row)',
|
||||
input: 'Update available · pkg · npm update 2026.3.11',
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
name: 'ignores pkg update in full table output',
|
||||
input: [
|
||||
'OpenClaw update status',
|
||||
'┌──────────┬─────────────────────────────────┐',
|
||||
@@ -62,7 +489,7 @@ describe('parseUpdateStatus', () => {
|
||||
'',
|
||||
'Update available (npm 2026.3.11). Run: openclaw update'
|
||||
].join('\n'),
|
||||
expected: '2026.3.11'
|
||||
expected: null
|
||||
},
|
||||
{ name: 'no update available', input: 'Already up to date', expected: null },
|
||||
{ name: 'empty string', input: '', expected: null },
|
||||
@@ -77,13 +504,16 @@ describe('parseUpdateStatus', () => {
|
||||
const results = Object.fromEntries(cases.map((c) => [c.name, parseUpdateStatus(c.input)]))
|
||||
expect(results).toMatchInlineSnapshot(`
|
||||
{
|
||||
"binary update via summary line": "2026.3.12",
|
||||
"binary update via table row": "2026.3.12",
|
||||
"binary update with semver": "1.2.3",
|
||||
"empty string": null,
|
||||
"full table output with update": "2026.3.11",
|
||||
"full table output with binary update": "2026.3.12",
|
||||
"ignores npm update (summary)": null,
|
||||
"ignores pkg update (table row)": null,
|
||||
"ignores pkg update in full table output": null,
|
||||
"no update available": null,
|
||||
"npm update available": "2026.3.11",
|
||||
"pkg update available": "2026.3.11",
|
||||
"unrelated output": null,
|
||||
"update available with semver": "1.2.3",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -9,10 +9,25 @@ export function parseCurrentVersion(versionOutput: string): string | null {
|
||||
|
||||
/**
|
||||
* Parse the update status from `openclaw update status` output.
|
||||
* Returns the latest version string if an update is available, otherwise null.
|
||||
* Example input: "Update available (npm 2026.3.11). Run: openclaw update"
|
||||
* Returns the latest version string if a **binary** update is available, otherwise null.
|
||||
*
|
||||
* Cherry Studio installs OpenClaw as a standalone binary, so we only care about
|
||||
* binary-channel updates. npm/pkg-channel updates are ignored because they
|
||||
* require a different upgrade path (`npm update -g`).
|
||||
*
|
||||
* The table output contains a row like:
|
||||
* │ Update │ available · binary · 2026.3.12 │
|
||||
* And a summary line like:
|
||||
* Update available (binary 2026.3.12). Run: openclaw update
|
||||
*/
|
||||
export function parseUpdateStatus(statusOutput: string): string | null {
|
||||
const match = statusOutput.match(/Update available.*?(\d[\d.]+)/i)
|
||||
return match?.[1] ?? null
|
||||
// Match binary-channel update from table row: "available · binary · <version>"
|
||||
const tableMatch = statusOutput.match(/available\s*·\s*binary\s*·?\s*([\d.]+)/i)
|
||||
if (tableMatch) return tableMatch[1]
|
||||
|
||||
// Match binary-channel update from summary line: "Update available (binary <version>)"
|
||||
const summaryMatch = statusOutput.match(/Update available\s*\(binary\s+([\d.]+)\)/i)
|
||||
if (summaryMatch) return summaryMatch[1]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -421,6 +421,9 @@ export function crossPlatformSpawn(
|
||||
args: string[],
|
||||
options: SpawnOptions & { env: Record<string, string> }
|
||||
): ChildProcess {
|
||||
// Always hide console window on Windows
|
||||
const baseOptions: SpawnOptions = { ...options, windowsHide: true, stdio: options.stdio ?? 'pipe' }
|
||||
|
||||
if (isWin && !command.toLowerCase().endsWith('.exe')) {
|
||||
// When shell: true, Node passes the command to cmd.exe as:
|
||||
// cmd /d /s /c "command arg1 arg2"
|
||||
@@ -428,9 +431,9 @@ export function crossPlatformSpawn(
|
||||
// cmd.exe splits on the space. Wrapping in quotes fixes this:
|
||||
// cmd /d /s /c ""C:\Program Files\nodejs\npm.cmd" arg1 arg2"
|
||||
const quotedCommand = command.includes(' ') && !command.startsWith('"') ? `"${command}"` : command
|
||||
return spawn(quotedCommand, args, { ...options, shell: true, stdio: options.stdio ?? 'pipe' })
|
||||
return spawn(quotedCommand, args, { ...baseOptions, shell: true })
|
||||
}
|
||||
return spawn(command, args, { ...options, stdio: options.stdio ?? 'pipe' })
|
||||
return spawn(command, args, baseOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -680,7 +680,6 @@ const api = {
|
||||
startGateway: (port?: number): Promise<OperationResult> =>
|
||||
ipcRenderer.invoke(IpcChannel.OpenClaw_StartGateway, port),
|
||||
stopGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_StopGateway),
|
||||
restartGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_RestartGateway),
|
||||
getStatus: (): Promise<{ status: OpenClawGatewayStatus; port: number }> =>
|
||||
ipcRenderer.invoke(IpcChannel.OpenClaw_GetStatus),
|
||||
checkHealth: (): Promise<OpenClawHealthInfo> => ipcRenderer.invoke(IpcChannel.OpenClaw_CheckHealth),
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@renderer/store/openclaw'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Alert, Avatar, Button, Result, Space, Spin } from 'antd'
|
||||
import { Download, ExternalLink, Play, RefreshCw, Square } from 'lucide-react'
|
||||
import { Download, ExternalLink, Play, Square } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -82,8 +82,6 @@ const OpenClawPage: FC = () => {
|
||||
const [isUninstalling, setIsUninstalling] = useState(false)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const [isRestarting, setIsRestarting] = useState(false)
|
||||
|
||||
// Install progress logs
|
||||
const [installLogs, setInstallLogs] = useState<Array<{ message: string; type: 'info' | 'warn' | 'error' }>>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
@@ -307,22 +305,6 @@ const OpenClawPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartGateway = async () => {
|
||||
setIsRestarting(true)
|
||||
try {
|
||||
const result = await window.api.openclaw.restartGateway()
|
||||
if (result.success) {
|
||||
dispatch(setGatewayStatus('running'))
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setIsRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenDashboard = async () => {
|
||||
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
|
||||
openSmartMinapp({
|
||||
@@ -413,8 +395,8 @@ const OpenClawPage: FC = () => {
|
||||
<div className="m-auto min-h-fit w-130">
|
||||
<TitleSection title={t('openclaw.title')} description={t('openclaw.description')} clickable docsUrl={docsUrl} />
|
||||
|
||||
{/* Install Path - hide when gateway is running or restarting */}
|
||||
{installPath && gatewayStatus !== 'running' && !isRestarting && (
|
||||
{/* Install Path - hide when gateway is running */}
|
||||
{installPath && gatewayStatus !== 'running' && (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
|
||||
@@ -451,8 +433,8 @@ const OpenClawPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gateway Status Card - show when running or restarting */}
|
||||
{(gatewayStatus === 'running' || isRestarting) && (
|
||||
{/* Gateway Status Card - show when running */}
|
||||
{gatewayStatus === 'running' && (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between rounded-lg p-3"
|
||||
style={{ background: 'var(--color-background-soft)' }}>
|
||||
@@ -465,27 +447,16 @@ const OpenClawPage: FC = () => {
|
||||
:{gatewayPort}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleRestartGateway}
|
||||
loading={isRestarting}
|
||||
disabled={isStopping || isRestarting}>
|
||||
{t('openclaw.gateway.restart')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<Square size={14} />}
|
||||
onClick={handleStopGateway}
|
||||
loading={isStopping}
|
||||
disabled={isStopping || isRestarting}
|
||||
danger>
|
||||
{t('openclaw.gateway.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<Square size={14} />}
|
||||
onClick={handleStopGateway}
|
||||
loading={isStopping}
|
||||
disabled={isStopping}
|
||||
danger>
|
||||
{t('openclaw.gateway.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -495,7 +466,7 @@ const OpenClawPage: FC = () => {
|
||||
<Alert
|
||||
message={
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="flex-1">{error}</span>
|
||||
<span className="max-h-25 flex-1 overflow-y-auto whitespace-pre-wrap break-all">{error}</span>
|
||||
<Button
|
||||
type="link"
|
||||
className="h-auto! w-3! shrink-0 p-0!"
|
||||
@@ -520,8 +491,8 @@ const OpenClawPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector - only show when not running and not restarting */}
|
||||
{gatewayStatus !== 'running' && !isRestarting && (
|
||||
{/* Model Selector - only show when not running */}
|
||||
{gatewayStatus !== 'running' && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
|
||||
{t('openclaw.model_config.model')}
|
||||
@@ -555,8 +526,7 @@ const OpenClawPage: FC = () => {
|
||||
|
||||
{showLogs && installLogs.length > 0 && renderLogContainer()}
|
||||
|
||||
{/* Action Button - hide when restarting */}
|
||||
{gatewayStatus !== 'running' && !isRestarting && (
|
||||
{gatewayStatus !== 'running' && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Play size={16} />}
|
||||
@@ -570,7 +540,7 @@ const OpenClawPage: FC = () => {
|
||||
{t('openclaw.gateway.start')}
|
||||
</Button>
|
||||
)}
|
||||
{(gatewayStatus === 'running' || isRestarting) && (
|
||||
{gatewayStatus === 'running' && (
|
||||
<Button type="primary" onClick={handleOpenDashboard} size="large" block>
|
||||
{t('openclaw.quick_actions.open_dashboard')}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user