diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx index 8d10edce5..2725618a4 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentsPage.tsx @@ -39,6 +39,7 @@ import { AgentTerminal } from './AgentTerminal' import { getOpenClawSupportedProviders } from './openclaw-supported-providers' import { type AgentEntry, + type GatewayLifecycleAction, type OpenClawStatus, useOpenClawAgents, useOpenClawMutations, @@ -46,6 +47,14 @@ import { usePodmanOverrides, } from './useOpenClaw' +const LIFECYCLE_BANNER_COPY: Record = { + setup: 'Setting up OpenClaw...', + start: 'Starting gateway...', + stop: 'Stopping gateway...', + restart: 'Restarting gateway...', + reconnect: 'Restoring gateway connection...', +} + const CONTROL_PLANE_COPY: Record< OpenClawStatus['controlPlaneStatus'], { @@ -372,6 +381,7 @@ export const AgentsPage: FC = () => { creating, deleting, reconnecting, + pendingGatewayAction, } = useOpenClawMutations() const [setupOpen, setSetupOpen] = useState(false) @@ -408,8 +418,13 @@ export const AgentsPage: FC = () => { setNewName((current) => current || 'agent') }, [createOpen]) - const inlineError = - error ?? statusError?.message ?? agentsError?.message ?? null + const lifecyclePending = pendingGatewayAction !== null + const inlineError = lifecyclePending + ? null + : (error ?? statusError?.message ?? agentsError?.message ?? null) + const lifecycleBanner = pendingGatewayAction + ? LIFECYCLE_BANNER_COPY[pendingGatewayAction] + : null const gatewayUiState = useMemo(() => { if (!status) { @@ -438,6 +453,10 @@ export const AgentsPage: FC = () => { } }, [status]) + const canManageAgents = gatewayUiState.canManageAgents && !lifecyclePending + const showControlPlaneDegraded = + !lifecyclePending && gatewayUiState.controlPlaneDegraded + const recoveryDetail = status ? getRecoveryDetail(status) : null const controlPlaneCopy = status ? getControlPlaneCopy(status.controlPlaneStatus) @@ -601,7 +620,7 @@ export const AgentsPage: FC = () => { @@ -875,7 +901,7 @@ export const AgentsPage: FC = () => { disabled={ !newName.trim() || creating || - !gatewayUiState.canManageAgents || + !canManageAgents || compatibleProviders.length === 0 } className="w-full" diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts index d00a770cf..859851ce0 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts @@ -67,6 +67,13 @@ export interface PodmanOverrides { effectivePodmanPath: string } +export type GatewayLifecycleAction = + | 'setup' + | 'start' + | 'stop' + | 'restart' + | 'reconnect' + async function clawFetch( baseUrl: string, path: string, @@ -224,6 +231,13 @@ export function useOpenClawMutations() { onSuccess, }) + let pendingGatewayAction: GatewayLifecycleAction | null = null + if (setupMutation.isPending) pendingGatewayAction = 'setup' + else if (restartMutation.isPending) pendingGatewayAction = 'restart' + else if (stopMutation.isPending) pendingGatewayAction = 'stop' + else if (startMutation.isPending) pendingGatewayAction = 'start' + else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect' + return { setupOpenClaw: setupMutation.mutateAsync, createAgent: createMutation.mutateAsync, @@ -244,6 +258,7 @@ export function useOpenClawMutations() { creating: createMutation.isPending, deleting: deleteMutation.isPending, reconnecting: reconnectMutation.isPending, + pendingGatewayAction, } } diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index 8589e79b3..d23362655 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -131,6 +131,7 @@ export class OpenClawService { private lastGatewayError: string | null = null private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null private stopLogTail: (() => void) | null = null + private lifecycleLock: Promise = Promise.resolve() constructor(config: OpenClawServiceConfig = {}) { this.openclawDir = getOpenClawDir() @@ -163,213 +164,250 @@ export class OpenClawService { // ── Lifecycle ──────────────────────────────────────────────────────── async setup(input: SetupInput, onLog?: (msg: string) => void): Promise { - const logProgress = this.createProgressLogger(onLog) - const provider = resolveSupportedOpenClawProvider(input) - logger.info('Starting OpenClaw setup', { - hostPort: this.hostPort, - browserosServerPort: this.browserosServerPort, - providerType: input.providerType, - providerName: input.providerName, - hasBaseUrl: !!input.baseUrl, - hasModel: !!input.modelId, - hasApiKey: !!input.apiKey, - }) + return this.withLifecycleLock('setup', async () => { + const logProgress = this.createProgressLogger(onLog) + const provider = resolveSupportedOpenClawProvider(input) + logger.info('Starting OpenClaw setup', { + hostPort: this.hostPort, + browserosServerPort: this.browserosServerPort, + providerType: input.providerType, + providerName: input.providerName, + hasBaseUrl: !!input.baseUrl, + hasModel: !!input.modelId, + hasApiKey: !!input.apiKey, + }) - logProgress('Checking container runtime...') - const available = await this.runtime.isPodmanAvailable() - if (!available) { - throw new Error( - 'Podman is not available. Install Podman to use OpenClaw agents.', - ) - } + logProgress('Checking container runtime...') + const available = await this.runtime.isPodmanAvailable() + if (!available) { + throw new Error( + 'Podman is not available. Install Podman to use OpenClaw agents.', + ) + } - await this.runtime.ensureReady(logProgress) - logProgress('Container runtime ready') + await this.runtime.ensureReady(logProgress) + logProgress('Container runtime ready') - await mkdir(this.openclawDir, { recursive: true }) - await mkdir(this.getStateDir(), { recursive: true }) - await mkdir(this.getHostWorkspaceDir('main'), { recursive: true }) + await mkdir(this.openclawDir, { recursive: true }) + await mkdir(this.getStateDir(), { recursive: true }) + await mkdir(this.getHostWorkspaceDir('main'), { recursive: true }) - await this.ensureStateEnvFile() - await this.writeStateEnv(provider.envValues) - logger.info('Updated OpenClaw state env', { - providerKeyCount: Object.keys(provider.envValues).length, - }) + await this.ensureStateEnvFile() + await this.writeStateEnv(provider.envValues) + logger.info('Updated OpenClaw state env', { + providerKeyCount: Object.keys(provider.envValues).length, + }) - logProgress('Pulling OpenClaw image...') - await this.runtime.pullImage(this.getGatewayImage(), logProgress) - logProgress('Image ready') - - await this.ensureGatewayPortAllocated(logProgress) - - logProgress('Bootstrapping OpenClaw config...') - await this.bootstrapCliClient.runOnboard({ - acceptRisk: true, - authChoice: 'skip', - gatewayAuth: 'token', - gatewayBind: 'lan', - gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT, - installDaemon: false, - mode: 'local', - nonInteractive: true, - skipHealth: true, - }) - await this.applyBrowserosConfig() - await this.mergeProviderConfigIfChanged(provider) - if (provider.model) { - await this.bootstrapCliClient.setDefaultModel(provider.model) - } + logProgress('Pulling OpenClaw image...') + await this.runtime.pullImage(this.getGatewayImage(), logProgress) + logProgress('Image ready') + + await this.ensureGatewayPortAllocated(logProgress) + + logProgress('Bootstrapping OpenClaw config...') + await this.bootstrapCliClient.runOnboard({ + acceptRisk: true, + authChoice: 'skip', + gatewayAuth: 'token', + gatewayBind: 'lan', + gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT, + installDaemon: false, + mode: 'local', + nonInteractive: true, + skipHealth: true, + }) + await this.applyBrowserosConfig() + await this.mergeProviderConfigIfChanged(provider) + if (provider.model) { + await this.bootstrapCliClient.setDefaultModel(provider.model) + } - logProgress('Validating OpenClaw config...') - await this.assertConfigValid(this.bootstrapCliClient) + logProgress('Validating OpenClaw config...') + await this.assertConfigValid(this.bootstrapCliClient) - this.tokenLoaded = false - await this.loadTokenFromConfig() + this.tokenLoaded = false + await this.loadTokenFromConfig() - logProgress('Starting OpenClaw gateway...') - await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress) - this.startGatewayLogTail() - logProgress('Waiting for gateway readiness...') - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - this.lastError = 'Gateway did not become ready within 30 seconds' - const logs = await this.runtime.getGatewayLogs() - logger.error('Gateway readiness check failed', { logs }) - throw new Error(this.lastError) - } + logProgress('Starting OpenClaw gateway...') + await this.runtime.startGateway( + this.buildGatewayRuntimeSpec(), + logProgress, + ) + this.startGatewayLogTail() + logProgress('Waiting for gateway readiness...') + const ready = await this.runtime.waitForReady( + this.hostPort, + READY_TIMEOUT_MS, + ) + if (!ready) { + this.lastError = 'Gateway did not become ready within 30 seconds' + const logs = await this.runtime.getGatewayLogs() + logger.error('Gateway readiness check failed', { logs }) + throw new Error(this.lastError) + } - this.controlPlaneStatus = 'connecting' - logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) + this.controlPlaneStatus = 'connecting' + logProgress('Probing OpenClaw control plane...') + await this.runControlPlaneCall(() => this.cliClient.probe()) - const existingAgents = await this.listAgents() - logger.info('Fetched existing OpenClaw agents after setup', { - count: existingAgents.length, - names: existingAgents.map((agent) => agent.name), - }) - if (existingAgents.some((agent) => agent.agentId === 'main')) { - logProgress('Main agent detected') - } else { - logProgress('Creating main agent...') - await this.runControlPlaneCall(() => - this.cliClient.createAgent({ - name: 'main', - model: provider.model, - }), - ) - } + const existingAgents = await this.listAgents() + logger.info('Fetched existing OpenClaw agents after setup', { + count: existingAgents.length, + names: existingAgents.map((agent) => agent.name), + }) + if (existingAgents.some((agent) => agent.agentId === 'main')) { + logProgress('Main agent detected') + } else { + logProgress('Creating main agent...') + await this.runControlPlaneCall(() => + this.cliClient.createAgent({ + name: 'main', + model: provider.model, + }), + ) + } - this.lastError = null - logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`) - logger.info('OpenClaw setup complete', { hostPort: this.hostPort }) + this.lastError = null + logProgress( + `OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`, + ) + logger.info('OpenClaw setup complete', { hostPort: this.hostPort }) + }) } async start(onLog?: (msg: string) => void): Promise { - const logProgress = this.createProgressLogger(onLog) - logger.info('Starting OpenClaw service', { - hostPort: this.hostPort, - }) + return this.withLifecycleLock('start', async () => { + const logProgress = this.createProgressLogger(onLog) + logger.info('Starting OpenClaw service', { + hostPort: this.hostPort, + }) - await this.runtime.ensureReady(logProgress) + await this.runtime.ensureReady(logProgress) - logProgress('Refreshing gateway auth token...') - this.tokenLoaded = false - await this.loadTokenFromConfig() - await this.ensureStateEnvFile() + logProgress('Refreshing gateway auth token...') + this.tokenLoaded = false + await this.loadTokenFromConfig() + await this.ensureStateEnvFile() - await this.ensureGatewayPortAllocated(logProgress) + await this.ensureGatewayPortAllocated(logProgress) + + if (await this.isGatewayAvailable(this.hostPort)) { + this.startGatewayLogTail() + this.controlPlaneStatus = 'connecting' + logProgress('Probing OpenClaw control plane...') + try { + await this.runControlPlaneCall(() => this.cliClient.probe()) + this.lastError = null + logger.info('OpenClaw gateway already running', { + hostPort: this.hostPort, + }) + return + } catch (error) { + logger.warn('OpenClaw control plane probe failed during start', { + hostPort: this.hostPort, + error: error instanceof Error ? error.message : String(error), + }) + } + } - logProgress('Starting OpenClaw gateway...') - await this.runtime.startGateway(this.buildGatewayRuntimeSpec(), logProgress) - this.startGatewayLogTail() + logProgress('Starting OpenClaw gateway...') + await this.runtime.startGateway( + this.buildGatewayRuntimeSpec(), + logProgress, + ) + this.startGatewayLogTail() - logProgress('Waiting for gateway readiness...') - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - this.lastError = 'Gateway did not become ready after start' - throw new Error(this.lastError) - } + logProgress('Waiting for gateway readiness...') + const ready = await this.runtime.waitForReady( + this.hostPort, + READY_TIMEOUT_MS, + ) + if (!ready) { + this.lastError = 'Gateway did not become ready after start' + throw new Error(this.lastError) + } - this.controlPlaneStatus = 'connecting' - logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - this.lastError = null - logger.info('OpenClaw gateway started', { hostPort: this.hostPort }) + this.controlPlaneStatus = 'connecting' + logProgress('Probing OpenClaw control plane...') + await this.runControlPlaneCall(() => this.cliClient.probe()) + this.lastError = null + logger.info('OpenClaw gateway started', { hostPort: this.hostPort }) + }) } async stop(): Promise { - logger.info('Stopping OpenClaw service', { hostPort: this.hostPort }) - this.controlPlaneStatus = 'disconnected' - this.stopGatewayLogTail() - await this.runtime.stopGateway() - logger.info('OpenClaw container stopped') + return this.withLifecycleLock('stop', async () => { + logger.info('Stopping OpenClaw service', { hostPort: this.hostPort }) + this.controlPlaneStatus = 'disconnected' + this.stopGatewayLogTail() + await this.runtime.stopGateway() + logger.info('OpenClaw container stopped') + }) } async restart(onLog?: (msg: string) => void): Promise { - const logProgress = this.createProgressLogger(onLog) - logger.info('Restarting OpenClaw service', { - hostPort: this.hostPort, - }) + return this.withLifecycleLock('restart', async () => { + const logProgress = this.createProgressLogger(onLog) + logger.info('Restarting OpenClaw service', { + hostPort: this.hostPort, + }) - this.controlPlaneStatus = 'reconnecting' - this.stopGatewayLogTail() - logProgress('Refreshing gateway auth token...') - this.tokenLoaded = false - await this.loadTokenFromConfig() - await this.ensureStateEnvFile() - await this.ensureGatewayPortAllocated(logProgress) - logProgress('Restarting OpenClaw gateway...') - await this.runtime.restartGateway( - this.buildGatewayRuntimeSpec(), - logProgress, - ) - this.startGatewayLogTail() + this.controlPlaneStatus = 'reconnecting' + this.stopGatewayLogTail() + logProgress('Refreshing gateway auth token...') + this.tokenLoaded = false + await this.loadTokenFromConfig() + await this.ensureStateEnvFile() + await this.ensureGatewayPortAllocated(logProgress) + logProgress('Restarting OpenClaw gateway...') + await this.runtime.restartGateway( + this.buildGatewayRuntimeSpec(), + logProgress, + ) + this.startGatewayLogTail() - logProgress('Waiting for gateway readiness...') - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - this.lastError = 'Gateway did not become ready after restart' - throw new Error(this.lastError) - } + logProgress('Waiting for gateway readiness...') + const ready = await this.runtime.waitForReady( + this.hostPort, + READY_TIMEOUT_MS, + ) + if (!ready) { + this.lastError = 'Gateway did not become ready after restart' + throw new Error(this.lastError) + } - logProgress('Probing OpenClaw control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - this.lastError = null - logProgress('Gateway restarted successfully') - logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort }) + logProgress('Probing OpenClaw control plane...') + await this.runControlPlaneCall(() => this.cliClient.probe()) + this.lastError = null + logProgress('Gateway restarted successfully') + logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort }) + }) } async reconnectControlPlane(onLog?: (msg: string) => void): Promise { - const logProgress = this.createProgressLogger(onLog) - logger.info('Reconnecting OpenClaw control plane', { - hostPort: this.hostPort, - }) + return this.withLifecycleLock('reconnect', async () => { + const logProgress = this.createProgressLogger(onLog) + logger.info('Reconnecting OpenClaw control plane', { + hostPort: this.hostPort, + }) - logProgress('Checking gateway readiness...') - const ready = await this.runtime.isReady(this.hostPort) - if (!ready) { - this.controlPlaneStatus = 'failed' - this.lastGatewayError = 'OpenClaw gateway is not ready' - this.lastRecoveryReason = 'container_not_ready' - throw new Error('OpenClaw gateway is not ready') - } + logProgress('Checking gateway readiness...') + const ready = await this.runtime.isReady(this.hostPort) + if (!ready) { + this.controlPlaneStatus = 'failed' + this.lastGatewayError = 'OpenClaw gateway is not ready' + this.lastRecoveryReason = 'container_not_ready' + throw new Error('OpenClaw gateway is not ready') + } - logProgress('Reloading gateway auth token...') - this.tokenLoaded = false - await this.loadTokenFromConfig() - this.controlPlaneStatus = 'reconnecting' - logProgress('Reconnecting control plane...') - await this.runControlPlaneCall(() => this.cliClient.probe()) - logProgress('Control plane connected') + logProgress('Reloading gateway auth token...') + this.tokenLoaded = false + await this.loadTokenFromConfig() + this.controlPlaneStatus = 'reconnecting' + logProgress('Reconnecting control plane...') + await this.runControlPlaneCall(() => this.cliClient.probe()) + logProgress('Control plane connected') + }) } async shutdown(): Promise { @@ -639,47 +677,49 @@ export class OpenClawService { // ── Auto-start on BrowserOS boot ──────────────────────────────────── async tryAutoStart(): Promise { - const isSetUp = existsSync(this.getStateConfigPath()) - if (!isSetUp) return - - const available = await this.runtime.isPodmanAvailable() - if (!available) return - logger.info('Attempting OpenClaw auto-start', { - hostPort: this.hostPort, - }) + return this.withLifecycleLock('auto-start', async () => { + const isSetUp = existsSync(this.getStateConfigPath()) + if (!isSetUp) return + + const available = await this.runtime.isPodmanAvailable() + if (!available) return + logger.info('Attempting OpenClaw auto-start', { + hostPort: this.hostPort, + }) - try { - await this.runtime.ensureReady() + try { + await this.runtime.ensureReady() - this.tokenLoaded = false - await this.loadTokenFromConfig() - await this.ensureStateEnvFile() + this.tokenLoaded = false + await this.loadTokenFromConfig() + await this.ensureStateEnvFile() - const persistedPort = await readPersistedGatewayPort(this.openclawDir) - if (persistedPort !== null) { - this.setPort(persistedPort) - } + const persistedPort = await readPersistedGatewayPort(this.openclawDir) + if (persistedPort !== null) { + this.setPort(persistedPort) + } - if (!(await this.runtime.isReady(this.hostPort))) { - await this.ensureGatewayPortAllocated() - await this.runtime.startGateway(this.buildGatewayRuntimeSpec()) - const ready = await this.runtime.waitForReady( - this.hostPort, - READY_TIMEOUT_MS, - ) - if (!ready) { - logger.warn('OpenClaw gateway failed to become ready on auto-start') - return + if (!(await this.runtime.isReady(this.hostPort))) { + await this.ensureGatewayPortAllocated() + await this.runtime.startGateway(this.buildGatewayRuntimeSpec()) + const ready = await this.runtime.waitForReady( + this.hostPort, + READY_TIMEOUT_MS, + ) + if (!ready) { + logger.warn('OpenClaw gateway failed to become ready on auto-start') + return + } } - } - await this.runControlPlaneCall(() => this.cliClient.probe()) - logger.info('OpenClaw gateway auto-started') - } catch (err) { - logger.warn('OpenClaw auto-start failed', { - error: err instanceof Error ? err.message : String(err), - }) - } + await this.runControlPlaneCall(() => this.cliClient.probe()) + logger.info('OpenClaw gateway auto-started') + } catch (err) { + logger.warn('OpenClaw auto-start failed', { + error: err instanceof Error ? err.message : String(err), + }) + } + }) } // ── Internal ───────────────────────────────────────────────────────── @@ -714,6 +754,13 @@ export class OpenClawService { private async ensureGatewayPortAllocated( logProgress?: (msg: string) => void, ): Promise { + const persistedPort = await readPersistedGatewayPort(this.openclawDir) + if (persistedPort !== null) { + this.setPort(persistedPort) + } + if (await this.isGatewayAvailable(this.hostPort)) { + return + } const hostPort = await allocateGatewayPort(this.openclawDir) if (hostPort !== this.hostPort) { logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`) @@ -722,6 +769,19 @@ export class OpenClawService { } } + private async isGatewayAvailable(hostPort: number): Promise { + if (await this.runtime.isReady(hostPort)) { + return true + } + const runtime = this.runtime as { + isHealthy?: (port: number) => Promise + } + if (runtime.isHealthy) { + return runtime.isHealthy(hostPort) + } + return false + } + private async assertGatewayReady(): Promise { const portReady = await this.runtime.isReady(this.hostPort) logger.debug('Checking OpenClaw gateway readiness before use', { @@ -1130,6 +1190,24 @@ export class OpenClawService { onLog?.(msg) } } + + private async withLifecycleLock( + operation: string, + fn: () => Promise, + ): Promise { + const previous = this.lifecycleLock + let release!: () => void + this.lifecycleLock = new Promise((resolve) => { + release = resolve + }) + await previous.catch(() => undefined) + try { + logger.debug('OpenClaw lifecycle operation started', { operation }) + return await fn() + } finally { + release() + } + } } let service: OpenClawService | null = null diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/podman-runtime.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/podman-runtime.ts index 8c93f2496..9d9b3aa6f 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/podman-runtime.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/podman-runtime.ts @@ -37,7 +37,6 @@ export function resolveBundledPodmanPath( export class PodmanRuntime { private podmanPath: string - private machineReady = false constructor(config?: { podmanPath?: string }) { this.podmanPath = config?.podmanPath ?? 'podman' @@ -138,12 +137,9 @@ export class PodmanRuntime { const code = await proc.exited if (code !== 0) throw new Error(`podman machine stop failed with code ${code}`) - this.machineReady = false } async ensureReady(onLog?: LogFn): Promise { - if (this.machineReady) return - const status = await this.getMachineStatus() if (!status.initialized) { @@ -155,8 +151,6 @@ export class PodmanRuntime { onLog?.('Starting Podman machine...') await this.startMachine(onLog) } - - this.machineReady = true } async runCommand( diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index 981bbd41f..d9470a31d 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw' @@ -23,7 +24,8 @@ type MutableOpenClawService = OpenClawService & { ensureReady?: () => Promise isPodmanAvailable?: () => Promise getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }> - isReady: () => Promise + isHealthy?: (_hostPort?: number) => Promise + isReady: (_hostPort?: number) => Promise pullImage?: ( _image: string, _onLog?: (_line: string) => void, @@ -573,7 +575,7 @@ describe('OpenClawService', () => { service.openclawDir = tempDir service.runtime = { ensureReady, - isReady: async () => true, + isReady: async () => false, startGateway, waitForReady, } @@ -598,6 +600,99 @@ describe('OpenClawService', () => { expect(probe).toHaveBeenCalledTimes(1) }) + it('serializes concurrent start calls and only starts the gateway once', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile( + join(tempDir, '.openclaw', 'openclaw.json'), + JSON.stringify({ + gateway: { + auth: { + token: 'cli-token', + }, + }, + }), + ) + let gatewayReady = false + let releaseStartGateway!: () => void + let notifyStartGatewayEntered!: () => void + const startGatewayEntered = new Promise((resolve) => { + notifyStartGatewayEntered = resolve + }) + const unblockStartGateway = new Promise((resolve) => { + releaseStartGateway = resolve + }) + const ensureReady = mock(async () => {}) + const startGateway = mock(async () => { + notifyStartGatewayEntered() + await unblockStartGateway + gatewayReady = true + }) + const waitForReady = mock(async () => true) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + ensureReady, + isReady: async () => gatewayReady, + startGateway, + waitForReady, + } + service.cliClient = { + probe, + } + + const firstStart = service.start() + await startGatewayEntered + const secondStart = service.start() + releaseStartGateway() + await Promise.all([firstStart, secondStart]) + + expect(ensureReady).toHaveBeenCalledTimes(2) + expect(startGateway).toHaveBeenCalledTimes(1) + expect(waitForReady).toHaveBeenCalledTimes(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('does not restart a ready gateway when start is called again', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile( + join(tempDir, '.openclaw', 'openclaw.json'), + JSON.stringify({ + gateway: { + auth: { + token: 'cli-token', + }, + }, + }), + ) + const ensureReady = mock(async () => {}) + const startGateway = mock(async () => {}) + const waitForReady = mock(async () => true) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + ensureReady, + isReady: async () => true, + startGateway, + waitForReady, + } + service.cliClient = { + probe, + } + + await service.start() + + expect(ensureReady).toHaveBeenCalledTimes(1) + expect(startGateway).not.toHaveBeenCalled() + expect(waitForReady).not.toHaveBeenCalled() + expect(probe).toHaveBeenCalledTimes(1) + }) + it('restart uses the direct runtime restartGateway flow', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) await mkdir(join(tempDir, '.openclaw'), { recursive: true }) @@ -642,6 +737,72 @@ describe('OpenClawService', () => { expect(probe).toHaveBeenCalledTimes(1) }) + it('restart keeps the persisted gateway port when the current gateway already owns it', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) + await mkdir(join(tempDir, '.openclaw'), { recursive: true }) + await writeFile( + join(tempDir, '.openclaw', 'openclaw.json'), + JSON.stringify({ + gateway: { + auth: { + token: 'cli-token', + }, + }, + }), + ) + const occupiedServer = createServer() + const occupiedPort = await new Promise((resolve, reject) => { + occupiedServer.once('error', reject) + occupiedServer.listen(0, '127.0.0.1', () => { + const address = occupiedServer.address() + if (!address || typeof address === 'string') { + reject(new Error('failed to allocate test port')) + return + } + resolve(address.port) + }) + }) + await writeFile( + join(tempDir, '.openclaw', 'runtime-state.json'), + `${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`, + ) + const restartGateway = mock(async () => {}) + const waitForReady = mock(async () => true) + const probe = mock(async () => {}) + const service = new OpenClawService() as MutableOpenClawService + + service.openclawDir = tempDir + service.runtime = { + isReady: async (hostPort?: number) => hostPort === occupiedPort, + restartGateway, + waitForReady, + } + service.cliClient = { + probe, + } + + try { + await service.restart() + } finally { + await new Promise((resolve, reject) => { + occupiedServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + + expect(restartGateway).toHaveBeenCalledWith( + expect.objectContaining({ + hostPort: occupiedPort, + }), + expect.any(Function), + ) + }) + it('stop calls runtime.stopGateway', async () => { const stopGateway = mock(async () => {}) const service = new OpenClawService() as MutableOpenClawService @@ -751,7 +912,7 @@ describe('OpenClawService', () => { ) expect(waitForReady).toHaveBeenCalledTimes(1) expect(probe).toHaveBeenCalledTimes(1) - expect(isReady).toHaveBeenCalledTimes(1) + expect(isReady).toHaveBeenCalledTimes(2) }) it('keeps openrouter model refs verbatim without rewriting dots', () => { diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/podman-runtime.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/podman-runtime.test.ts index efc1a3531..ef2c7382a 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/podman-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/podman-runtime.test.ts @@ -11,9 +11,43 @@ import path from 'node:path' import { configurePodmanRuntime, getPodmanRuntime, + PodmanRuntime, resolveBundledPodmanPath, } from '../../../../src/api/services/openclaw/podman-runtime' +class FakePodmanRuntime extends PodmanRuntime { + machineStatuses: Array<{ initialized: boolean; running: boolean }> + initCalls = 0 + startCalls = 0 + statusCalls = 0 + + constructor(statuses: Array<{ initialized: boolean; running: boolean }>) { + super({ podmanPath: 'podman' }) + this.machineStatuses = [...statuses] + } + + async getMachineStatus(): Promise<{ + initialized: boolean + running: boolean + }> { + this.statusCalls += 1 + return ( + this.machineStatuses.shift() ?? { + initialized: true, + running: true, + } + ) + } + + async initMachine(): Promise { + this.initCalls += 1 + } + + async startMachine(): Promise { + this.startCalls += 1 + } +} + describe('podman runtime', () => { let tempDir: string @@ -80,4 +114,56 @@ describe('podman runtime', () => { expect(runtime.getPodmanPath()).toBe('podman') }) + + it('ensureReady re-checks machine status on every call', async () => { + const runtime = new FakePodmanRuntime([ + { initialized: true, running: true }, + { initialized: true, running: true }, + { initialized: true, running: true }, + ]) + + await runtime.ensureReady() + await runtime.ensureReady() + await runtime.ensureReady() + + expect(runtime.statusCalls).toBe(3) + expect(runtime.initCalls).toBe(0) + expect(runtime.startCalls).toBe(0) + }) + + it('ensureReady initializes when machine is not present', async () => { + const runtime = new FakePodmanRuntime([ + { initialized: false, running: false }, + ]) + + await runtime.ensureReady() + + expect(runtime.statusCalls).toBe(1) + expect(runtime.initCalls).toBe(1) + expect(runtime.startCalls).toBe(1) + }) + + it('ensureReady starts when machine is initialized but stopped', async () => { + const runtime = new FakePodmanRuntime([ + { initialized: true, running: false }, + ]) + + await runtime.ensureReady() + + expect(runtime.initCalls).toBe(0) + expect(runtime.startCalls).toBe(1) + }) + + it('ensureReady detects an externally stopped machine on the next call', async () => { + const runtime = new FakePodmanRuntime([ + { initialized: true, running: true }, + { initialized: true, running: false }, + ]) + + await runtime.ensureReady() + await runtime.ensureReady() + + expect(runtime.statusCalls).toBe(2) + expect(runtime.startCalls).toBe(1) + }) })