Compare commits

...

3 Commits

Author SHA1 Message Date
Nikhil Sonti
61b2c60b29 fix: address review comments for openclaw-lifecycle-progress 2026-04-20 19:03:39 -07:00
Nikhil Sonti
574735ab0b feat(openclaw): lifecycle progress banner and live podman readiness check 2026-04-20 19:03:39 -07:00
Nikhil Sonti
3131b2907d fix(openclaw): serialize lifecycle operations 2026-04-20 19:02:23 -07:00
6 changed files with 604 additions and 244 deletions

View File

@@ -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<GatewayLifecycleAction, string> = {
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 = () => {
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
@@ -612,6 +631,13 @@ export const AgentsPage: FC = () => {
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
@@ -631,7 +657,7 @@ export const AgentsPage: FC = () => {
</Alert>
)}
{status && gatewayUiState.controlPlaneDegraded && (
{status && showControlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
@@ -752,7 +778,7 @@ export const AgentsPage: FC = () => {
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
@@ -781,7 +807,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
disabled={!canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
@@ -791,7 +817,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!gatewayUiState.canManageAgents || deleting}
disabled={!canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
@@ -875,7 +901,7 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
!gatewayUiState.canManageAgents ||
!canManageAgents ||
compatibleProviders.length === 0
}
className="w-full"

View File

@@ -67,6 +67,13 @@ export interface PodmanOverrides {
effectivePodmanPath: string
}
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
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,
}
}

View File

@@ -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<void> = 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<void> {
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 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,
})
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)
this.tokenLoaded = false
await this.loadTokenFromConfig()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
logProgress,
)
}
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 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('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
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)
}
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,
}),
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.lastError = null
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`)
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
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,
}),
)
}
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<void> {
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)
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
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('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 })
})
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
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)
}
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<void> {
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<void> {
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()
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 })
})
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('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<void> {
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('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('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')
}
async shutdown(): Promise<void> {
@@ -639,47 +677,49 @@ export class OpenClawService {
// ── Auto-start on BrowserOS boot ────────────────────────────────────
async tryAutoStart(): Promise<void> {
const isSetUp = existsSync(this.getStateConfigPath())
if (!isSetUp) return
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()
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
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
}
}
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),
const available = await this.runtime.isPodmanAvailable()
if (!available) return
logger.info('Attempting OpenClaw auto-start', {
hostPort: this.hostPort,
})
}
try {
await this.runtime.ensureReady()
this.tokenLoaded = false
await this.loadTokenFromConfig()
await this.ensureStateEnvFile()
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
}
}
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<void> {
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<boolean> {
if (await this.runtime.isReady(hostPort)) {
return true
}
const runtime = this.runtime as {
isHealthy?: (port: number) => Promise<boolean>
}
if (runtime.isHealthy) {
return runtime.isHealthy(hostPort)
}
return false
}
private async assertGatewayReady(): Promise<void> {
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<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = this.lifecycleLock
let release!: () => void
this.lifecycleLock = new Promise<void>((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

View File

@@ -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<void> {
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(

View File

@@ -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<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isReady: () => Promise<boolean>
isHealthy?: (_hostPort?: number) => Promise<boolean>
isReady: (_hostPort?: number) => Promise<boolean>
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<void>((resolve) => {
notifyStartGatewayEntered = resolve
})
const unblockStartGateway = new Promise<void>((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<number>((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<void>((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', () => {

View File

@@ -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<void> {
this.initCalls += 1
}
async startMachine(): Promise<void> {
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)
})
})