chore: prepare GoodBuddy 0.8.4
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled

This commit is contained in:
lofyer
2026-08-07 02:39:18 +08:00
parent 4100911c34
commit e20cb447af
11 changed files with 297 additions and 37 deletions
+2 -2
View File
@@ -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
View File
@@ -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', () => {
+10 -4
View File
@@ -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(
() =>
+43 -14
View File
@@ -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)
}
}
+4 -4
View File
@@ -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,
+10
View File
@@ -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({
+1 -1
View File
@@ -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', [