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
77 changes: 51 additions & 26 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,58 @@ export default class Webui extends Command {
public async run(): Promise<void> {
const {flags} = await this.parse(Webui)

let webuiPort: number
const webuiPort = flags.port ? await this.resolveSetPort(flags.port) : await this.resolveGetPort()
const url = `http://localhost:${webuiPort}`
Comment thread
ncnthien marked this conversation as resolved.
this.log(`ByteRover Web UI: ${url}`)

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

private async resolveGetPort(): Promise<number> {
let result: WebuiGetPortResponse
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
result = await withDaemonRetry(
async (client) => client.requestWithAck<WebuiGetPortResponse>(WebuiEvents.GET_PORT),
{projectPath: process.cwd()},
)
} catch (error) {
return this.error(formatConnectionError(error))
}

if (result.status === 'ok') {
if (result.requestedPort !== undefined && result.requestedPort !== result.port) {
this.log(`Port ${result.requestedPort} was in use — using port ${result.port} instead.`)
}

return result.port
}
Comment thread
ncnthien marked this conversation as resolved.

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.')
}

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

const url = `http://localhost:${webuiPort}`
this.log(`ByteRover Web UI: ${url}`)
if (result.status === 'ok') return result.port

await open(url).catch(() => {
this.log('Could not open browser automatically. Open the URL above manually.')
})
return this.error(
`Web UI port ${result.conflictPort} is already in use. Run \`brv webui --port <port>\` to choose a different port.`,
)
}
Comment thread
ncnthien marked this conversation as resolved.
}
1 change: 1 addition & 0 deletions src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const PORT_BATCH_SIZE = 20
export const PORT_MAX_ATTEMPTS = 5
// Web UI (stable port, separate from dynamic transport port)
export const WEBUI_DEFAULT_PORT = 7700
export const WEBUI_MAX_FALLBACK_ATTEMPTS = 10
export const WEBUI_STATE_FILE = 'webui.json'
// Heartbeat
export const HEARTBEAT_FILE = 'heartbeat'
Expand Down
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'
}
}
93 changes: 74 additions & 19 deletions src/server/infra/daemon/brv-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,22 @@ 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,
BRV_DIR,
HEARTBEAT_FILE,
TASK_HEARTBEAT_INTERVAL_MS,
WEBUI_DEFAULT_PORT,
WEBUI_MAX_FALLBACK_ATTEMPTS,
} from '../../constants.js'
import {WebUiPortInUseError} from '../../core/domain/errors/webui-error.js'
import {
type ProviderConfigResponse,
type TaskCurateResultEvent,
Expand Down Expand Up @@ -92,6 +100,7 @@ import {IdleTimeoutPolicy} from './idle-timeout-policy.js'
import {selectDaemonPort} from './port-selector.js'
import {bootstrapSettings} from './settings-bootstrap.js'
import {ShutdownHandler} from './shutdown-handler.js'
import {startWebUiWithFallback} from './start-webui-with-fallback.js'

function log(msg: string): void {
processLog(`[Daemon] ${msg}`)
Expand Down Expand Up @@ -196,6 +205,8 @@ 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'}
let webuiRequestedPort: number | undefined

try {
// 4a. Construct transport server. start() is deferred to step 11 so all handlers register before sockets connect.
Expand All @@ -207,29 +218,51 @@ async function main(): Promise<void> {
const webuiDistDir = join(projectRoot, 'dist', 'webui')
// Port priority: env var > persisted preference > default
const webuiPortEnv = process.env.BRV_WEBUI_PORT
const webuiPort = webuiPortEnv
? Number.parseInt(webuiPortEnv, 10)
: (readWebuiPreferredPort() ?? WEBUI_DEFAULT_PORT)
const explicitWebuiPort = webuiPortEnv ? Number.parseInt(webuiPortEnv, 10) : readWebuiPreferredPort()
const webuiPreferredPort = explicitWebuiPort ?? WEBUI_DEFAULT_PORT
// Explicit user intent (env/persisted) is honored strictly. The default
// port is the only one that may fall back to neighbors when busy.
const webuiMaxAttempts = explicitWebuiPort === undefined ? WEBUI_MAX_FALLBACK_ATTEMPTS : 1
Comment thread
ncnthien marked this conversation as resolved.

const webuiApp = createWebUiMiddleware({
getConfig: () => ({daemonPort: port, port: webuiPort, projectCwd: process.cwd(), version}),
getConfig: () => ({
daemonPort: port,
port: webuiServer?.getPort() ?? webuiPreferredPort,
projectCwd: process.cwd(),
version,
}),
webuiDistDir,
})

const app = express()
app.use(webuiApp)

webuiServer = new WebUiServer(app)
Comment thread
ncnthien marked this conversation as resolved.
try {
await webuiServer.start(webuiPort)
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)}`)
const webuiOutcome = await startWebUiWithFallback(webuiServer, webuiPreferredPort, webuiMaxAttempts)

if (webuiOutcome.status === 'ok') {
writeWebuiState(webuiOutcome.actualPort)
if (webuiOutcome.actualPort === webuiOutcome.requestedPort) {
log(`Web UI server started on port ${webuiOutcome.actualPort}`)
} else {
webuiRequestedPort = webuiOutcome.requestedPort
log(`Web UI port ${webuiOutcome.requestedPort} was in use — started on ${webuiOutcome.actualPort} instead`)
}
} else {
webuiServer = undefined
if (webuiOutcome.error instanceof WebUiPortInUseError) {
webuiBootFailure = {conflictPort: webuiPreferredPort, status: 'port_in_use'}
Comment thread
ncnthien marked this conversation as resolved.
const rangeEnd = webuiPreferredPort + webuiMaxAttempts - 1
log(
webuiMaxAttempts > 1
? `Web UI ports ${webuiPreferredPort}-${rangeEnd} are all in use — Web UI unavailable`
: `Web UI port ${webuiPreferredPort} is already in use — Web UI unavailable`,
)
} else {
log(
`Web UI start error: ${webuiOutcome.error instanceof Error ? webuiOutcome.error.message : String(webuiOutcome.error)}`,
)
}
}

// 5. Start heartbeat writer. Must run before transport.start(): pollForDaemon SIGTERMs daemons with stale heartbeat.
Expand Down Expand Up @@ -565,12 +598,19 @@ 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 activePort = webuiServer?.getPort()
if (activePort !== undefined) {
return webuiRequestedPort !== undefined && webuiRequestedPort !== activePort
? {port: activePort, requestedPort: webuiRequestedPort, status: 'ok'}
: {port: activePort, 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 +629,27 @@ 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
webuiRequestedPort = 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
31 changes: 31 additions & 0 deletions src/server/infra/daemon/start-webui-with-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {WebUiPortInUseError} from '../../core/domain/errors/webui-error.js'

export interface WebUiStarter {
start(port: number): Promise<void>
}

export type StartWebUiOutcome =
| {actualPort: number; requestedPort: number; status: 'ok'}
| {error: unknown; status: 'error'}
Comment thread
ncnthien marked this conversation as resolved.

export async function startWebUiWithFallback(
server: WebUiStarter,
preferredPort: number,
maxAttempts: number,
): Promise<StartWebUiOutcome> {
let lastError: unknown
for (let offset = 0; offset < maxAttempts; offset++) {
const attemptPort = preferredPort + offset
try {
// eslint-disable-next-line no-await-in-loop -- intentional sequential fallback
await server.start(attemptPort)
return {actualPort: attemptPort, requestedPort: preferredPort, status: 'ok'}
} catch (error) {
lastError = error
if (error instanceof WebUiPortInUseError) continue
break
}
}

return {error: lastError, status: 'error'}
}
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
16 changes: 16 additions & 0 deletions src/shared/transport/events/webui-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const WebuiEvents = {
GET_PORT: 'webui:getPort',
SET_PORT: 'webui:setPort',
} as const

type WebuiGetPortOkResponse = {port: number; requestedPort?: number; status: 'ok'}
type WebuiPortInUseResponse = {conflictPort: number; status: 'port_in_use'}
type WebuiSetPortOkResponse = {port: number; status: 'ok'}

export type WebuiGetPortResponse = WebuiGetPortOkResponse | WebuiPortInUseResponse | {status: 'not_started'}

export interface WebuiSetPortRequest {
port: number
}

export type WebuiSetPortResponse = WebuiPortInUseResponse | WebuiSetPortOkResponse
Comment thread
ncnthien marked this conversation as resolved.
Loading
Loading