Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 40 additions & 27 deletions src/oclif/commands/webui.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {Command, Flags} from '@oclif/core'
import open from 'open'

import {
WebuiEvents,
type WebuiGetPortResponse,
type WebuiSetPortResponse,
} from '../../shared/transport/events/webui-events.js'
import {formatConnectionError, withDaemonRetry} from '../lib/daemon-client.js'

export default class Webui extends Command {
Expand All @@ -16,38 +21,46 @@ export default class Webui extends Command {
public async run(): Promise<void> {
const {flags} = await this.parse(Webui)

let webuiPort: number

try {
// If --port is provided, tell the daemon to switch to that port and persist it
if (flags.port) {
const result = await withDaemonRetry(
async (client) =>
client.requestWithAck<{port: number; success: boolean}>('webui:setPort', {port: flags.port}),
{projectPath: process.cwd()},
)
webuiPort = result.port
} else {
const result = await withDaemonRetry(
async (client) => client.requestWithAck<{port?: number}>('webui:getPort'),
{projectPath: process.cwd()},
)

if (!result.port) {
this.error('Failed to get web UI port. Use `brv restart` to restart the daemon and try again')
}

webuiPort = result.port
}
} catch (error) {
this.error(formatConnectionError(error))
}

const result = flags.port ? await this.requestSetPort(flags.port) : await this.requestGetPort()
const webuiPort = this.resolvePortOrExit(result)
const url = `http://localhost:${webuiPort}`
this.log(`ByteRover Web UI: ${url}`)

await open(url).catch(() => {
this.log('Could not open browser automatically. Open the URL above manually.')
})
}

private async requestGetPort(): Promise<WebuiGetPortResponse> {
try {
return await withDaemonRetry(
async (client) => client.requestWithAck<WebuiGetPortResponse>(WebuiEvents.GET_PORT),
{projectPath: process.cwd()},
)
} catch (error) {
return this.error(formatConnectionError(error))
}
}

private async requestSetPort(port: number): Promise<WebuiSetPortResponse> {
try {
return await withDaemonRetry(
async (client) => client.requestWithAck<WebuiSetPortResponse>(WebuiEvents.SET_PORT, {port}),
{projectPath: process.cwd()},
)
} catch (error) {
return this.error(formatConnectionError(error))
}
}

private resolvePortOrExit(result: WebuiGetPortResponse | WebuiSetPortResponse): number {
if (result.status === 'ok') return result.port
if (result.status === 'port_in_use') {
return this.error(
`Web UI port ${result.conflictPort} is already in use. Run \`brv webui --port <port>\` to choose a different port.`,
)
}

return this.error('Web UI did not start. Run `brv restart` and try again.')
}
Comment thread
ncnthien marked this conversation as resolved.
}
23 changes: 23 additions & 0 deletions src/server/core/domain/errors/webui-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export class WebUiError extends Error {
public constructor(message: string) {
super(message)
this.name = 'WebUiError'
}
}

export class WebUiPortInUseError extends WebUiError {
public readonly port: number

public constructor(port: number) {
super(`Web UI port ${port} is already in use`)
this.name = 'WebUiPortInUseError'
this.port = port
}
}

export class WebUiServerAlreadyRunningError extends WebUiError {
public constructor() {
super('Web UI server is already running')
this.name = 'WebUiServerAlreadyRunningError'
}
}
47 changes: 37 additions & 10 deletions src/server/infra/daemon/brv-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import type {BrvConfig} from '../../core/domain/entities/brv-config.js'

import {ReviewEvents} from '../../../shared/transport/events/review-events.js'
import {TaskEvents, type TaskHeartbeatEvent} from '../../../shared/transport/events/task-events.js'
import {
WebuiEvents,
type WebuiGetPortResponse,
type WebuiSetPortRequest,
type WebuiSetPortResponse,
} from '../../../shared/transport/events/webui-events.js'
import {
AGENT_IDLE_CHECK_INTERVAL_MS,
AGENT_IDLE_TIMEOUT_MS,
Expand All @@ -41,6 +47,7 @@ import {
TASK_HEARTBEAT_INTERVAL_MS,
WEBUI_DEFAULT_PORT,
} from '../../constants.js'
import {WebUiPortInUseError} from '../../core/domain/errors/webui-error.js'
import {
type ProviderConfigResponse,
type TaskCurateResultEvent,
Expand Down Expand Up @@ -196,6 +203,7 @@ async function main(): Promise<void> {
let authStateStore: AuthStateStore | undefined
let agentPool: AgentPool | undefined
let webuiServer: undefined | WebUiServer
let webuiBootFailure: undefined | {conflictPort: number; status: 'port_in_use'}

try {
// 4a. Construct transport server. start() is deferred to step 11 so all handlers register before sockets connect.
Expand Down Expand Up @@ -225,10 +233,13 @@ async function main(): Promise<void> {
writeWebuiState(webuiPort)
log(`Web UI server started on port ${webuiPort}`)
} catch (webuiError) {
log(
`Web UI port ${webuiPort} is already in use. Web UI will not be available. Set BRV_WEBUI_PORT=<port> to use a different port.`,
)
log(`Web UI start error: ${webuiError instanceof Error ? webuiError.message : String(webuiError)}`)
if (webuiError instanceof WebUiPortInUseError) {
webuiBootFailure = {conflictPort: webuiError.port, status: 'port_in_use'}
log(`Web UI port ${webuiError.port} is already in use — Web UI unavailable`)
} else {
log(`Web UI start error: ${webuiError instanceof Error ? webuiError.message : String(webuiError)}`)
}

webuiServer = undefined
}

Expand Down Expand Up @@ -565,12 +576,14 @@ async function main(): Promise<void> {
})

// Web UI port endpoint — used by `brv webui` to discover the stable port
transportServer.onRequest<void, {port?: number}>('webui:getPort', () => ({
port: webuiServer?.getPort(),
}))
transportServer.onRequest<void, WebuiGetPortResponse>(WebuiEvents.GET_PORT, () => {
const port = webuiServer?.getPort()
if (port !== undefined) return {port, status: 'ok'}
return webuiBootFailure ?? {status: 'not_started'}
})

// Web UI set port — restarts webui server on new port and persists preference
transportServer.onRequest<{port: number}, {port: number; success: boolean}>('webui:setPort', async (data) => {
transportServer.onRequest<WebuiSetPortRequest, WebuiSetPortResponse>(WebuiEvents.SET_PORT, async (data) => {
const newPort = data.port

// Stop existing webui server if running
Expand All @@ -589,12 +602,26 @@ async function main(): Promise<void> {

// Start on new port
webuiServer = new WebUiServer(newApp)
await webuiServer.start(newPort)
try {
await webuiServer.start(newPort)
} catch (error) {
webuiServer = undefined
if (error instanceof WebUiPortInUseError) {
webuiBootFailure = {conflictPort: error.port, status: 'port_in_use'}
log(`Web UI port ${error.port} is already in use — Web UI unavailable`)
return {conflictPort: error.port, status: 'port_in_use'}
}

webuiBootFailure = undefined
throw error
Comment thread
ncnthien marked this conversation as resolved.
}
Comment thread
ncnthien marked this conversation as resolved.

webuiBootFailure = undefined
writeWebuiState(newPort)
writeWebuiPreferredPort(newPort)
log(`Web UI server restarted on port ${newPort} (persisted)`)

return {port: newPort, success: true}
return {port: newPort, status: 'ok'}
})

// Debug endpoint — exposes daemon internal state for `brv debug` command
Expand Down
13 changes: 7 additions & 6 deletions src/server/infra/webui/webui-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {Express} from 'express'
import {createServer, type Server as HttpServer} from 'node:http'

import {TRANSPORT_HOST} from '../../constants.js'
import {WebUiPortInUseError, WebUiServerAlreadyRunningError} from '../../core/domain/errors/webui-error.js'

/**
* Standalone HTTP server for the web UI.
Expand All @@ -28,21 +29,21 @@ export class WebUiServer {

async start(port: number): Promise<void> {
if (this.running) {
throw new Error('Web UI server is already running')
throw new WebUiServerAlreadyRunningError()
}

return new Promise((resolve, reject) => {
this.httpServer = createServer(this.app)
let resolved = false

this.httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Web UI port ${port} is already in use`))
} else {
reject(err)
}
if (resolved) return
this.httpServer = undefined
Comment thread
ncnthien marked this conversation as resolved.
reject(err.code === 'EADDRINUSE' ? new WebUiPortInUseError(port) : err)
})

this.httpServer.listen(port, TRANSPORT_HOST, () => {
resolved = true
const addr = this.httpServer?.address()
this.port = typeof addr === 'object' && addr !== null ? addr.port : port
this.running = true
Expand Down
3 changes: 3 additions & 0 deletions src/shared/transport/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export * from './status-events.js'
export * from './task-events.js'
export * from './team-events.js'
export * from './vc-events.js'
export * from './webui-events.js'
export * from './worktree-events.js'

// Utility exports
Expand Down Expand Up @@ -59,6 +60,7 @@ import {StatusEvents} from './status-events.js'
import {TaskEvents} from './task-events.js'
import {TeamEvents} from './team-events.js'
import {VcEvents} from './vc-events.js'
import {WebuiEvents} from './webui-events.js'
import {WorktreeEvents} from './worktree-events.js'

/**
Expand Down Expand Up @@ -93,6 +95,7 @@ export const AllEventGroups = [
TaskEvents,
TeamEvents,
VcEvents,
WebuiEvents,
WorktreeEvents,
] as const

Expand Down
15 changes: 15 additions & 0 deletions src/shared/transport/events/webui-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const WebuiEvents = {
GET_PORT: 'webui:getPort',
SET_PORT: 'webui:setPort',
} as const

export type WebuiGetPortResponse =
| {conflictPort: number; status: 'port_in_use'}
| {port: number; status: 'ok'}
| {status: 'not_started'}

export interface WebuiSetPortRequest {
port: number
}

export type WebuiSetPortResponse = {conflictPort: number; status: 'port_in_use'} | {port: number; status: 'ok'}
33 changes: 33 additions & 0 deletions test/unit/core/domain/errors/webui-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {expect} from 'chai'

import {
WebUiError,
WebUiPortInUseError,
WebUiServerAlreadyRunningError,
} from '../../../../../src/server/core/domain/errors/webui-error.js'

describe('webui-error', () => {
describe('WebUiPortInUseError', () => {
it('exposes the conflicting port and a descriptive message', () => {
const error = new WebUiPortInUseError(7700)

expect(error).to.be.an.instanceOf(WebUiError)
expect(error).to.be.an.instanceOf(Error)
expect(error.name).to.equal('WebUiPortInUseError')
expect(error.port).to.equal(7700)
expect(error.message).to.include('7700')
expect(error.message).to.include('in use')
})
})

describe('WebUiServerAlreadyRunningError', () => {
it('identifies itself by name and extends WebUiError', () => {
const error = new WebUiServerAlreadyRunningError()

expect(error).to.be.an.instanceOf(WebUiError)
expect(error).to.be.an.instanceOf(Error)
expect(error.name).to.equal('WebUiServerAlreadyRunningError')
expect(error.message).to.include('already running')
})
})
})
27 changes: 23 additions & 4 deletions test/unit/infra/webui/webui-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {expect} from 'chai'
import express from 'express'
import {createServer} from 'node:http'

import {WebUiPortInUseError, WebUiServerAlreadyRunningError} from '../../../../src/server/core/domain/errors/webui-error.js'
import {WebUiServer} from '../../../../src/server/infra/webui/webui-server.js'

describe('WebUiServer', () => {
Expand Down Expand Up @@ -34,7 +35,7 @@ describe('WebUiServer', () => {
expect(server.getPort()).to.be.undefined
})

it('should reject with error when port is in use', async () => {
it('should reject with WebUiPortInUseError when port is in use', async () => {
// Occupy a port first
const blockingServer = createServer()
const occupiedPort = await new Promise<number>((resolve, reject) => {
Expand All @@ -53,11 +54,13 @@ describe('WebUiServer', () => {
await server.start(occupiedPort)
expect.fail('Expected start to reject')
} catch (error) {
expect(error).to.be.an.instanceOf(Error)
expect(error).to.be.an.instanceOf(WebUiPortInUseError)
expect((error as WebUiPortInUseError).port).to.equal(occupiedPort)
expect((error as Error).message).to.include('in use')
}

expect(server.isRunning()).to.be.false
expect(server.getPort()).to.be.undefined
} finally {
blockingServer.close()
}
Expand All @@ -70,13 +73,29 @@ describe('WebUiServer', () => {
await server.start(0)
expect.fail('Expected start to reject')
} catch (error) {
expect(error).to.be.an.instanceOf(Error)
expect((error as Error).message).to.include('already running')
expect(error).to.be.an.instanceOf(WebUiServerAlreadyRunningError)
}
})

it('should be a no-op to stop when not running', async () => {
server = new WebUiServer(express())
await server.stop() // should not throw
})

it('should ignore post-startup error emissions so stop() still cleans up', async () => {
server = new WebUiServer(express())
await server.start(0)
const boundPort = server.getPort()
expect(boundPort).to.be.a('number')

const internal = server as unknown as {httpServer?: {emit(event: string, err: Error): boolean}}
internal.httpServer?.emit('error', Object.assign(new Error('ECONNRESET'), {code: 'ECONNRESET'}))

expect(server.isRunning()).to.be.true
expect(server.getPort()).to.equal(boundPort)

await server.stop()
expect(server.isRunning()).to.be.false
expect(server.getPort()).to.be.undefined
})
})
Loading