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
58 changes: 43 additions & 15 deletions src/oclif/commands/curate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {CurateLogOperation} from '../../../server/core/domain/entities/cura

import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../server/constants.js'
import {ProviderConfigResponse, TransportStateEventNames} from '../../../server/core/domain/transport/index.js'
import {formatBlockedCurationMessage, isBlockedCurationResponse} from '../../../server/utils/curate-outcome.js'
import {extractCurateOperations} from '../../../server/utils/curate-result-parser.js'
import {TaskEvents} from '../../../shared/transport/events/index.js'
import {printBillingLine} from '../../lib/billing-line.js'
Expand Down Expand Up @@ -154,7 +155,16 @@ Bad examples:
await ensureBillingFunds({billing, client})
}

await this.submitTask({client, content: resolvedContent, flags, format, projectRoot, taskType, worktreeRoot})
await this.submitTask({
client,
content: resolvedContent,
flags,
format,
projectRoot,
providerContext,
taskType,
worktreeRoot,
})
},
{
...this.getDaemonClientOptions(),
Expand Down Expand Up @@ -199,20 +209,22 @@ Bad examples:
* Best-effort enrichment: returns per-file detail when tool results include needsReview.
* The authoritative signal for whether review is required comes from ReviewEvents.NOTIFY.
*/
private collectPendingReviewOps(toolCalls: ToolCallRecord[]): CurateLogOperation[] {
private collectCurateOperations(toolCalls: ToolCallRecord[]): CurateLogOperation[] {
const pending: CurateLogOperation[] = []

for (const tc of toolCalls) {
if (tc.status !== 'completed') continue
const ops = extractCurateOperations({result: tc.result, toolName: tc.toolName})
for (const op of ops) {
if (op.needsReview === true) pending.push(op)
}
pending.push(...ops)
}

return pending
}

private collectPendingReviewOps(toolCalls: ToolCallRecord[]): CurateLogOperation[] {
return this.collectCurateOperations(toolCalls).filter((op) => op.needsReview === true)
}

/**
* Extract file changes from collected tool calls (same logic as TUI useActivityLogs).
*/
Expand Down Expand Up @@ -287,12 +299,12 @@ Bad examples:
}

private reportError(error: unknown, format: 'json' | 'text', providerContext?: ProviderErrorContext): void {
const errorMessage = error instanceof Error ? error.message : 'Curate failed'
const errorMessage = formatConnectionError(error, providerContext)

if (format === 'json') {
writeJsonResponse({command: 'curate', data: {error: errorMessage, status: 'error'}, success: false})
} else {
this.log(formatConnectionError(error, providerContext))
this.log(errorMessage)
}

if (hasLeakedHandles(error)) {
Expand All @@ -307,10 +319,11 @@ Bad examples:
flags: CurateFlags
format: 'json' | 'text'
projectRoot?: string
providerContext?: ProviderErrorContext
taskType: string
worktreeRoot?: string
}): Promise<void> {
const {client, content, flags, format, projectRoot, taskType, worktreeRoot} = props
const {client, content, flags, format, projectRoot, providerContext, taskType, worktreeRoot} = props
const hasFolders = Boolean(flags.folder?.length)
const taskId = randomUUID()
const taskPayload = {
Expand Down Expand Up @@ -344,10 +357,20 @@ Bad examples:
client,
command: 'curate',
format,
onCompleted: ({logId, pendingReview, taskId: tid, toolCalls}) => {
onCompleted: ({logId, pendingReview, result, taskId: tid, toolCalls}) => {
const changes = this.composeChangesFromToolCalls(toolCalls)
const operations = this.collectCurateOperations(toolCalls)
const hasAppliedOperations = operations.some((op) => op.status === 'success')
const hasFailedOperations = operations.some((op) => op.status === 'failed')
const blocked = !hasAppliedOperations && isBlockedCurationResponse(result)
// Per-file detail is best-effort enrichment; server notify is authoritative
const pendingOps = pendingReview ? this.collectPendingReviewOps(toolCalls) : []
const suffix = logId ? ` (Task: ${tid} · Log: ${logId})` : ` (Task: ${tid})`
const message = blocked
? formatBlockedCurationMessage(result)
: !hasAppliedOperations && !hasFailedOperations && !pendingReview
? 'No context changes applied'
: 'Context curated successfully'

if (format === 'text') {
for (const file of changes.created) {
Expand All @@ -358,8 +381,9 @@ Bad examples:
this.log(` update ${file}`)
}

const suffix = logId ? ` (Task: ${tid} · Log: ${logId})` : ` (Task: ${tid})`
this.log(`✓ Context curated successfully.${suffix}`)
const icon =
blocked ? '✗' : hasAppliedOperations || hasFailedOperations || pendingReview ? '✓' : 'ℹ'
this.log(`${icon} ${message}.${suffix}`)

if (pendingReview) {
this.printPendingReviewSummary(pendingReview.pendingCount, pendingOps, tid)
Expand All @@ -371,22 +395,26 @@ Bad examples:
changes: changes.created.length > 0 || changes.updated.length > 0 ? changes : undefined,
event: 'completed',
logId,
message: 'Context curated successfully',
message,
...(pendingReview
? {pendingReview: this.buildPendingReviewJson(pendingReview.pendingCount, pendingOps, tid)}
: {}),
status: 'completed',
status: blocked ? 'error' : 'completed',
taskId: tid,
},
success: true,
success: !blocked,
})
}
},
onError({error, logId}) {
if (format === 'json') {
const message = formatConnectionError(
Object.assign(new Error(error.message), error.code ? {code: error.code} : {}),
providerContext,
)
writeJsonResponse({
command: 'curate',
data: {event: 'error', logId, message: error.message, status: 'error'},
data: {event: 'error', logId, message, status: 'error'},
success: false,
})
}
Expand Down
14 changes: 10 additions & 4 deletions src/oclif/commands/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Bad:
client,
format,
projectRoot,
providerContext,
query: args.query,
worktreeRoot,
})
Expand All @@ -129,12 +130,12 @@ Bad:
}

private reportError(error: unknown, format: 'json' | 'text', providerContext?: ProviderErrorContext): void {
const errorMessage = error instanceof Error ? error.message : 'Query failed'
const errorMessage = formatConnectionError(error, providerContext)

if (format === 'json') {
writeJsonResponse({command: 'query', data: {error: errorMessage, status: 'error'}, success: false})
} else {
this.log(formatConnectionError(error, providerContext))
this.log(errorMessage)
}

if (hasLeakedHandles(error)) {
Expand All @@ -147,10 +148,11 @@ Bad:
client: ITransportClient
format: 'json' | 'text'
projectRoot?: string
providerContext?: ProviderErrorContext
query: string
worktreeRoot?: string
}): Promise<void> {
const {client, format, projectRoot, query, worktreeRoot} = props
const {client, format, projectRoot, providerContext, query, worktreeRoot} = props
const taskId = randomUUID()
const taskPayload = {
clientCwd: process.cwd(),
Expand Down Expand Up @@ -213,9 +215,13 @@ Bad:
},
onError({error}) {
if (format === 'json') {
const message = formatConnectionError(
Object.assign(new Error(error.message), error.code ? {code: error.code} : {}),
providerContext,
)
writeJsonResponse({
command: 'query',
data: {event: 'error', message: error.message, status: 'error'},
data: {event: 'error', message, status: 'error'},
success: false,
})
}
Expand Down
43 changes: 35 additions & 8 deletions src/oclif/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const USER_FRIENDLY_MESSAGES: Record<string, string> = {
[TaskErrorCode.CONTEXT_TREE_NOT_INITIALIZED]: 'Context tree not initialized.',
[TaskErrorCode.LOCAL_CHANGES_EXIST]: 'You have local changes. Run "brv push" to save your changes before pulling.',
[TaskErrorCode.NOT_AUTHENTICATED]:
'Not authenticated. Cloud sync features (push/pull/space) require login — local query and curate work without authentication.',
'Not authenticated. Cloud sync features (push/pull/space) require login. Run "brv login" to connect your account.',
[TaskErrorCode.OAUTH_REFRESH_FAILED]:
'OAuth token refresh failed. Run "brv providers connect <provider> --oauth" to reconnect.',
[TaskErrorCode.OAUTH_TOKEN_EXPIRED]:
Expand Down Expand Up @@ -209,32 +209,35 @@ export function formatConnectionError(error: unknown, providerContext?: Provider
}

if (error instanceof ConnectionFailedError) {
const isSandboxError = isSandboxNetworkError(error.originalError ?? error)
const connectionFailedError = error as {message?: string; originalError?: unknown; port?: number}
const isSandboxError = isSandboxNetworkError((connectionFailedError.originalError ?? error) as Error | string)

if (isSandboxError) {
const sandboxName = getSandboxEnvironmentName()
return (
`Failed to connect to the daemon.\n` +
`Port: ${error.port ?? 'unknown'}\n` +
`Port: ${connectionFailedError.port ?? 'unknown'}\n` +
`⚠️ Sandbox network restriction detected (${sandboxName}).\n\n` +
`Please allow network access in the sandbox and retry the command.`
)
}

return `Failed to connect to the daemon: ${error.message}\nRun 'brv restart' if the daemon is unresponsive.`
return `Failed to connect to the daemon: ${connectionFailedError.message ?? 'unknown error'}\nRun 'brv restart' if the daemon is unresponsive.`
}

if (error instanceof ConnectionError) {
return `Connection error: ${error.message}\nRun 'brv restart' if the daemon is unresponsive.`
const connectionError = error as {message?: string}
return `Connection error: ${connectionError.message ?? 'unknown error'}\nRun 'brv restart' if the daemon is unresponsive.`
}

// Business errors from transport handlers (auth, validation, etc.)
if (error instanceof TransportRequestError) {
const transportError = error as {code?: unknown; message?: string}
// Strip the " for event '...'" suffix that TransportRequestError appends
const baseMessage = error.message.replace(/ for event '[^']+'$/, '')
const baseMessage = (transportError.message ?? 'Transport request failed').replace(/ for event '[^']+'$/, '')

if (error.code && typeof error.code === 'string') {
return USER_FRIENDLY_MESSAGES[error.code] ?? baseMessage
if (typeof transportError.code === 'string') {
return USER_FRIENDLY_MESSAGES[transportError.code] ?? baseMessage
}

return baseMessage
Expand All @@ -253,6 +256,10 @@ export function formatConnectionError(error: unknown, providerContext?: Provider
const lowerMessage = message.toLowerCase()

if (lowerMessage.includes('401') || lowerMessage.includes('unauthorized')) {
if (isNonByteRoverProvider(providerContext)) {
return formatProviderUnauthorizedError(providerContext)
}

return "Authentication required for cloud sync. Run 'brv login' to connect your account."
}

Expand All @@ -263,6 +270,11 @@ export function formatConnectionError(error: unknown, providerContext?: Provider
return `Unexpected error: ${message}`
}

function isNonByteRoverProvider(providerContext?: ProviderErrorContext): boolean {
const provider = providerContext?.activeProvider
return Boolean(provider && provider !== 'byterover')
}

function formatApiKeyError(providerContext?: ProviderErrorContext): string {
const provider = providerContext?.activeProvider ?? '<provider>'
const model = providerContext?.activeModel
Expand All @@ -277,3 +289,18 @@ function formatApiKeyError(providerContext?: ProviderErrorContext): string {
' See all options: brv providers --help'
)
}

function formatProviderUnauthorizedError(providerContext?: ProviderErrorContext): string {
const provider = providerContext?.activeProvider ?? '<provider>'
const model = providerContext?.activeModel
const currentInfo = model ? `Provider: ${provider} Model: ${model}\n\n` : `Provider: ${provider}\n\n`
const modelFlag = model ? ` --model ${model}` : ' --model <model>'
const baseUrlFlag = provider === 'openai-compatible' ? ' --base-url <url>' : ''

return (
`LLM provider request was unauthorized.\n${currentInfo}` +
'Reconnect the active provider with a valid API key/token and base URL:\n' +
` brv providers connect ${provider}${baseUrlFlag} --api-key <token>${modelFlag}\n\n` +
'Cloud sync login is not required for local query/curate unless using the ByteRover provider.'
)
}
19 changes: 19 additions & 0 deletions src/server/infra/process/curate-log-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {TaskInfo} from '../../core/domain/transport/task-info.js'
import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js'
import type {ICurateLogStore} from '../../core/interfaces/storage/i-curate-log-store.js'

import {formatBlockedCurationMessage, isBlockedCurationResponse} from '../../utils/curate-outcome.js'
import {extractCurateOperations} from '../../utils/curate-result-parser.js'
import {getProjectDataDir} from '../../utils/path-utils.js'
import {transportLog} from '../../utils/process-logger.js'
Expand Down Expand Up @@ -166,6 +167,24 @@ export class CurateLogHandler implements ITaskLifecycleHook {

const store = this.getOrCreateStore(state.projectPath)

if (state.operations.length === 0 && isBlockedCurationResponse(result)) {
const updated: CurateLogEntry = {
...state.entry,
completedAt: Date.now(),
error: formatBlockedCurationMessage(result),
operations: state.operations,
status: 'error',
summary: computeSummary(state.operations),
}

await store.save(updated).catch((error: unknown) => {
transportLog(
`CurateLogHandler: failed to save blocked entry for ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
)
})
return
}

const updated: CurateLogEntry = {
...state.entry,
completedAt: Date.now(),
Expand Down
20 changes: 20 additions & 0 deletions src/server/utils/curate-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const CURATION_BLOCKED_PATTERNS = [
/\bcontext curation blocked\b/i,
/\bi am blocked\b.{0,160}\b(?:curat(?:e|ion|ing)|code_exec|tool(?:ing|s)?|rlm|sandbox|session)\b/i,
/\b(?:the curation agent|this curation)\b.{0,120}\b(?:blocked|could not|cannot|can't|unable to)\b/i,
/\b(?:cannot|can't|could not|unable to)\b.{0,120}\b(?:complete|perform|do|run|access|verify|use|call)\b.{0,120}\b(?:proper\s+)?(?:rlm\s+)?curat(?:e|ion|ing)\b/i,
/\b(?:required|necessary)\b.{0,80}\b(?:code_exec|tooling|tools?)\b.{0,80}\b(?:not|missing|unavailable|exposed|provided|registered)\b/i,
/\b(?:code_exec|tooling|tools?)\b.{0,80}\b(?:not|missing|unavailable|exposed|provided|registered)\b.{0,80}\b(?:in this session|for (?:this|the) curation|to complete)\b/i,
]

export function isBlockedCurationResponse(response?: string): boolean {
if (!response?.trim()) return false
return CURATION_BLOCKED_PATTERNS.some((pattern) => pattern.test(response))
}

export function formatBlockedCurationMessage(response?: string): string {
const firstLine = response?.split('\n').map((line) => line.trim()).find(Boolean)
if (!firstLine) return 'Context curation blocked'
const suffix = firstLine.length > 240 ? `${firstLine.slice(0, 237)}...` : firstLine
return `Context curation blocked: ${suffix}`
}
Loading
Loading