chore: prepare GoodBuddy 0.8.4
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -31,6 +31,11 @@ describe('embedding error classification', () => {
|
||||
timedOut: true
|
||||
}).code
|
||||
).toBe('timeout')
|
||||
const timeout = new Error('Embedding request timed out')
|
||||
timeout.name = 'TimeoutError'
|
||||
expect(
|
||||
classifyEmbeddingError(timeout, { cancelled: true }).code
|
||||
).toBe('timeout')
|
||||
})
|
||||
|
||||
it('never returns provider bodies, credentials, endpoints or nested causes', () => {
|
||||
|
||||
@@ -105,16 +105,22 @@ function classifyCode(
|
||||
const text = errorText(error)
|
||||
const status = numericStatus(error) ?? statusFromText(text)
|
||||
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return 'timeout'
|
||||
}
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (options.timedOut) {
|
||||
return 'timeout'
|
||||
}
|
||||
if (
|
||||
options.cancelled ||
|
||||
hasAny(text, ['aborterror', 'aborted', 'cancelled', 'canceled'])
|
||||
) {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (
|
||||
options.timedOut ||
|
||||
hasAny(text, ['timeout', 'timed out', 'etimedout'])
|
||||
) {
|
||||
if (hasAny(text, ['timeout', 'timed out', 'etimedout'])) {
|
||||
return 'timeout'
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -181,6 +181,89 @@ describe('EmbeddingIndexCoordinator', () => {
|
||||
expect(JSON.stringify(result)).not.toContain('private payload')
|
||||
})
|
||||
|
||||
it('preserves a provider timeout when the diagnostic signal aborts later', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository)
|
||||
const controller = new AbortController()
|
||||
let rejectProvider:
|
||||
| ((reason?: unknown) => void)
|
||||
| undefined
|
||||
const diagnostic = coordinator.diagnose(
|
||||
provider(
|
||||
() =>
|
||||
new Promise<number[][]>((_resolve, reject) => {
|
||||
rejectProvider = reject
|
||||
})
|
||||
),
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(rejectProvider).toBeDefined()
|
||||
})
|
||||
|
||||
const timeout = new Error('Embedding request timed out')
|
||||
timeout.name = 'TimeoutError'
|
||||
controller.abort()
|
||||
rejectProvider?.(timeout)
|
||||
|
||||
await expect(diagnostic).resolves.toMatchObject({
|
||||
status: 'unavailable',
|
||||
error: {
|
||||
code: 'timeout'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps diagnostics independent from an active rebuild cancellation', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
let rebuildSignal: AbortSignal | undefined
|
||||
let resolveDiagnostic: ((vectors: number[][]) => void) | undefined
|
||||
const embed = vi.fn<EmbeddingIndexProvider['embed']>(
|
||||
(input, signal) => {
|
||||
if (input[0] === 'GoodBuddy 向量模型连接测试') {
|
||||
return new Promise<number[][]>((resolve) => {
|
||||
resolveDiagnostic = resolve
|
||||
})
|
||||
}
|
||||
return new Promise<number[][]>((_resolve, reject) => {
|
||||
rebuildSignal = signal
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(signal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
const sharedProvider = provider(embed)
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
createId: () => 'job-concurrent'
|
||||
})
|
||||
coordinator.startRebuild(sharedProvider)
|
||||
await vi.waitFor(() => {
|
||||
expect(rebuildSignal).toBeDefined()
|
||||
})
|
||||
|
||||
const diagnostic = coordinator.diagnose(sharedProvider)
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveDiagnostic).toBeDefined()
|
||||
})
|
||||
expect(coordinator.cancel('job-concurrent')).toBe(true)
|
||||
resolveDiagnostic?.([[0.25, 0.5, 0.75]])
|
||||
|
||||
await expect(diagnostic).resolves.toMatchObject({
|
||||
status: 'available',
|
||||
dimensions: 3
|
||||
})
|
||||
await expect(coordinator.waitForCompletion()).resolves.toMatchObject({
|
||||
status: 'cancelled'
|
||||
})
|
||||
expect(embed.mock.calls).toContainEqual([
|
||||
['GoodBuddy 向量模型连接测试'],
|
||||
undefined
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces each document atomically and persists completed progress', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
@@ -320,6 +403,42 @@ describe('EmbeddingIndexCoordinator', () => {
|
||||
expect(repository.lastJob).toEqual(cancelled)
|
||||
})
|
||||
|
||||
it('preserves a provider timeout when rebuild cancellation arrives later', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
createId: () => 'job-timeout'
|
||||
})
|
||||
let rejectProvider:
|
||||
| ((reason?: unknown) => void)
|
||||
| undefined
|
||||
coordinator.startRebuild(
|
||||
provider(
|
||||
() =>
|
||||
new Promise<number[][]>((_resolve, reject) => {
|
||||
rejectProvider = reject
|
||||
})
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(rejectProvider).toBeDefined()
|
||||
})
|
||||
|
||||
const timeout = new Error('Embedding request timed out')
|
||||
timeout.name = 'TimeoutError'
|
||||
expect(coordinator.cancel('job-timeout')).toBe(true)
|
||||
rejectProvider?.(timeout)
|
||||
|
||||
await expect(coordinator.waitForCompletion()).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'timeout'
|
||||
}
|
||||
})
|
||||
expect(repository.errors.get('document-1')).toBe(
|
||||
'向量服务响应超时。'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks an interrupted persisted job cancelled during initialization', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
repository.lastJob = {
|
||||
|
||||
@@ -431,13 +431,15 @@ export class EmbeddingIndexCoordinator {
|
||||
await this.repository
|
||||
.discardDocumentReplacement(replacementId)
|
||||
.catch(() => undefined)
|
||||
if (signal.aborted) {
|
||||
throw error
|
||||
}
|
||||
const safeError =
|
||||
error instanceof EmbeddingOperationError
|
||||
? error.toSafeError()
|
||||
: classifyEmbeddingError(error)
|
||||
: classifyEmbeddingError(error, {
|
||||
cancelled: signal.aborted
|
||||
})
|
||||
if (safeError.code === 'cancelled') {
|
||||
throw error
|
||||
}
|
||||
await this.repository.recordDocumentError(
|
||||
document.id,
|
||||
provider.provider,
|
||||
@@ -467,9 +469,13 @@ export class EmbeddingIndexCoordinator {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const cancelled =
|
||||
signal.aborted ||
|
||||
classifyEmbeddingError(error).code === 'cancelled'
|
||||
const safeError =
|
||||
error instanceof EmbeddingOperationError
|
||||
? error.toSafeError()
|
||||
: classifyEmbeddingError(error, {
|
||||
cancelled: signal.aborted
|
||||
})
|
||||
const cancelled = safeError.code === 'cancelled'
|
||||
this.updateJob(
|
||||
cancelled
|
||||
? {
|
||||
@@ -479,10 +485,7 @@ export class EmbeddingIndexCoordinator {
|
||||
: {
|
||||
status: 'failed',
|
||||
completedAt: this.now(),
|
||||
error:
|
||||
error instanceof EmbeddingOperationError
|
||||
? error.toSafeError()
|
||||
: classifyEmbeddingError(error)
|
||||
error: safeError
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,94 @@ describe('OpenAIEmbeddingClient', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes its request timeout from caller cancellation', async () => {
|
||||
const waitForAbort = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
const timedClient = new OpenAIEmbeddingClient({
|
||||
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
||||
model: 'nomic-embed-text',
|
||||
timeoutMs: 100,
|
||||
fetch: waitForAbort
|
||||
})
|
||||
|
||||
await expect(
|
||||
timedClient.embed(['safe synthetic input'])
|
||||
).rejects.toMatchObject({
|
||||
name: 'TimeoutError',
|
||||
message: 'Embedding request timed out'
|
||||
})
|
||||
|
||||
const caller = new AbortController()
|
||||
const cancelled = timedClient.embed(
|
||||
['safe synthetic input'],
|
||||
caller.signal
|
||||
)
|
||||
caller.abort(new Error('caller cancelled'))
|
||||
await expect(cancelled).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Embedding request was cancelled'
|
||||
})
|
||||
|
||||
let rejectTransport:
|
||||
| ((reason?: unknown) => void)
|
||||
| undefined
|
||||
let transportSignal: AbortSignal | null | undefined
|
||||
const delayedTransport = vi.fn<typeof fetch>(
|
||||
async (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
transportSignal = init?.signal
|
||||
rejectTransport = reject
|
||||
})
|
||||
)
|
||||
const delayedClient = new OpenAIEmbeddingClient({
|
||||
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
||||
model: 'nomic-embed-text',
|
||||
timeoutMs: 100,
|
||||
fetch: delayedTransport
|
||||
})
|
||||
const lateCaller = new AbortController()
|
||||
const timeoutThenCancellation = delayedClient.embed(
|
||||
['safe synthetic input'],
|
||||
lateCaller.signal
|
||||
)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(transportSignal?.aborted).toBe(true)
|
||||
expect(transportSignal?.reason).toMatchObject({
|
||||
name: 'TimeoutError'
|
||||
})
|
||||
},
|
||||
{ interval: 5, timeout: 500 }
|
||||
)
|
||||
lateCaller.abort()
|
||||
rejectTransport?.(transportSignal?.reason)
|
||||
await expect(timeoutThenCancellation).rejects.toMatchObject({
|
||||
name: 'TimeoutError',
|
||||
message: 'Embedding request timed out'
|
||||
})
|
||||
|
||||
const preCancelled = new AbortController()
|
||||
preCancelled.abort(new Error('caller cancelled before request'))
|
||||
await expect(
|
||||
delayedClient.embed(
|
||||
['safe synthetic input'],
|
||||
preCancelled.signal
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
name: 'AbortError',
|
||||
message: 'Embedding request was cancelled'
|
||||
})
|
||||
expect(delayedTransport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects unsafe endpoints and malformed vectors', async () => {
|
||||
expect(
|
||||
() =>
|
||||
|
||||
@@ -9,7 +9,8 @@ const MAX_URL_LENGTH = 2_048
|
||||
const MAX_DIMENSIONS = 8_192
|
||||
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
const MIN_TIMEOUT_MS = 100
|
||||
const MAX_TIMEOUT_MS = 120_000
|
||||
const DEFAULT_TIMEOUT_MS = 120_000
|
||||
const MAX_TIMEOUT_MS = DEFAULT_TIMEOUT_MS
|
||||
|
||||
export interface OpenAIEmbeddingClientOptions {
|
||||
endpoint: string
|
||||
@@ -64,6 +65,18 @@ function normalizedEndpoint(input: string): string {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function embeddingAbortError(
|
||||
requestSignal: AbortSignal,
|
||||
timeoutError: Error
|
||||
): Error {
|
||||
if (requestSignal.reason === timeoutError) {
|
||||
return timeoutError
|
||||
}
|
||||
const error = new Error('Embedding request was cancelled')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
if (
|
||||
@@ -187,7 +200,7 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
||||
MAX_BATCH_SIZE
|
||||
)
|
||||
this.timeoutMs = boundedInteger(
|
||||
options.timeoutMs ?? 15_000,
|
||||
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
'timeoutMs',
|
||||
MIN_TIMEOUT_MS,
|
||||
MAX_TIMEOUT_MS
|
||||
@@ -255,11 +268,19 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
||||
input: readonly string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<number[][]> {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason
|
||||
const timeoutError = new Error('Embedding request timed out')
|
||||
timeoutError.name = 'TimeoutError'
|
||||
const timeoutController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutController.abort(timeoutError)
|
||||
}, this.timeoutMs)
|
||||
const requestSignal = signal
|
||||
? AbortSignal.any([signal, timeoutController.signal])
|
||||
: timeoutController.signal
|
||||
if (requestSignal.aborted) {
|
||||
clearTimeout(timeoutId)
|
||||
throw embeddingAbortError(requestSignal, timeoutError)
|
||||
}
|
||||
const timeout = AbortSignal.timeout(this.timeoutMs)
|
||||
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
@@ -267,7 +288,7 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
||||
if (this.apiKey) {
|
||||
headers.authorization = `Bearer ${this.apiKey}`
|
||||
}
|
||||
let response: Response
|
||||
let response: Response | undefined
|
||||
try {
|
||||
response = await this.transport(this.endpoint, {
|
||||
method: 'POST',
|
||||
@@ -276,17 +297,25 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
||||
redirect: 'error',
|
||||
signal: requestSignal
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Embedding request failed with HTTP ${response.status}`
|
||||
)
|
||||
}
|
||||
return validateEmbeddings(
|
||||
await readBoundedJson(response),
|
||||
input.length
|
||||
)
|
||||
} catch (error) {
|
||||
if (requestSignal.aborted) {
|
||||
const abortError = new Error('Embedding request was cancelled')
|
||||
abortError.name = 'AbortError'
|
||||
throw abortError
|
||||
throw embeddingAbortError(requestSignal, timeoutError)
|
||||
}
|
||||
if (response) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Embedding request failed', { cause: error })
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Embedding request failed with HTTP ${response.status}`)
|
||||
}
|
||||
return validateEmbeddings(await readBoundedJson(response), input.length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ describe('App', () => {
|
||||
it('checks for updates silently on startup and only reports a new version', async () => {
|
||||
const check = vi.fn(async () => ({
|
||||
updateAvailable: true,
|
||||
currentVersion: '0.8.3',
|
||||
currentVersion: '0.8.4',
|
||||
latestVersion: '0.9.0',
|
||||
releaseUrl:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.9.0',
|
||||
@@ -595,10 +595,10 @@ describe('App', () => {
|
||||
it('does not disturb startup when updates are current or offline', async () => {
|
||||
const currentResult = {
|
||||
updateAvailable: false,
|
||||
currentVersion: '0.8.3',
|
||||
latestVersion: '0.8.3',
|
||||
currentVersion: '0.8.4',
|
||||
latestVersion: '0.8.4',
|
||||
releaseUrl:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.3',
|
||||
'https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.4',
|
||||
target: {
|
||||
platform: 'windows' as const,
|
||||
arch: 'x64' as const,
|
||||
|
||||
@@ -23,6 +23,16 @@ describe('embedding contracts', () => {
|
||||
latencyMs: 184,
|
||||
dimensions: 1_536
|
||||
})
|
||||
expect(
|
||||
embeddingDiagnosticResultSchema.safeParse({
|
||||
status: 'available',
|
||||
provider: 'provider',
|
||||
model: 'slow-local-model',
|
||||
checkedAt: 1_700_000_000_000,
|
||||
latencyMs: 120_001,
|
||||
dimensions: 4_096
|
||||
}).success
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
embeddingDiagnosticResultSchema.safeParse({
|
||||
|
||||
@@ -56,7 +56,7 @@ const embeddingDiagnosticBase = {
|
||||
provider: boundedLabelSchema,
|
||||
model: boundedLabelSchema,
|
||||
checkedAt: timestampSchema,
|
||||
latencyMs: countSchema.max(120_000)
|
||||
latencyMs: countSchema
|
||||
}
|
||||
|
||||
export const embeddingDiagnosticResultSchema = z.discriminatedUnion('status', [
|
||||
|
||||
Reference in New Issue
Block a user