chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
EmbeddingIndexJob,
|
||||
EmbeddingIndexStatus
|
||||
} from '../../shared/embedding-contracts'
|
||||
import {
|
||||
EmbeddingIndexCoordinator,
|
||||
type EmbeddingIndexProvider,
|
||||
type EmbeddingIndexRecord,
|
||||
type EmbeddingIndexRepository
|
||||
} from './embedding-index-coordinator'
|
||||
|
||||
class MemoryRepository implements EmbeddingIndexRepository {
|
||||
readonly documents = [
|
||||
{
|
||||
id: 'document-1',
|
||||
items: [
|
||||
{ id: 'chunk-1', content: 'alpha', contentChecksum: 'sum-1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'document-2',
|
||||
items: [
|
||||
{ id: 'chunk-2', content: 'beta', contentChecksum: 'sum-2' },
|
||||
{ id: 'chunk-3', content: 'gamma', contentChecksum: 'sum-3' }
|
||||
]
|
||||
}
|
||||
]
|
||||
readonly records = new Map<string, readonly EmbeddingIndexRecord[]>([
|
||||
[
|
||||
'document-1',
|
||||
[{ itemId: 'chunk-1', contentChecksum: 'sum-1', vector: [0, 1] }]
|
||||
],
|
||||
[
|
||||
'document-2',
|
||||
[
|
||||
{ itemId: 'chunk-2', contentChecksum: 'sum-2', vector: [0, 1] },
|
||||
{ itemId: 'chunk-3', contentChecksum: 'sum-3', vector: [0, 1] }
|
||||
]
|
||||
]
|
||||
])
|
||||
readonly errors = new Map<string, string>()
|
||||
readonly pendingRecords = new Map<
|
||||
string,
|
||||
{ documentId: string; records: EmbeddingIndexRecord[] }
|
||||
>()
|
||||
readonly events: string[] = []
|
||||
lastJob: EmbeddingIndexJob | null = null
|
||||
readonly savedStatuses: EmbeddingIndexStatus[] = []
|
||||
|
||||
async getLastJob(): Promise<EmbeddingIndexJob | null> {
|
||||
return this.lastJob
|
||||
}
|
||||
|
||||
async saveStatus(status: EmbeddingIndexStatus): Promise<void> {
|
||||
this.savedStatuses.push(status)
|
||||
this.lastJob = status.job
|
||||
}
|
||||
|
||||
async listIndexDocumentIds(): Promise<string[]> {
|
||||
return this.documents.map((document) => document.id)
|
||||
}
|
||||
|
||||
async getIndexDocument(
|
||||
documentId: string
|
||||
): Promise<(typeof this.documents)[number] | undefined> {
|
||||
this.events.push(`load:${documentId}`)
|
||||
return this.documents.find((document) => document.id === documentId)
|
||||
}
|
||||
|
||||
async beginDocumentReplacement(
|
||||
documentId: string
|
||||
): Promise<string> {
|
||||
const replacementId = `replacement-${documentId}`
|
||||
this.pendingRecords.set(replacementId, {
|
||||
documentId,
|
||||
records: []
|
||||
})
|
||||
this.events.push(`begin:${documentId}`)
|
||||
return replacementId
|
||||
}
|
||||
|
||||
async appendDocumentReplacement(
|
||||
replacementId: string,
|
||||
_documentId: string,
|
||||
_provider: string,
|
||||
_model: string,
|
||||
records: readonly EmbeddingIndexRecord[]
|
||||
): Promise<void> {
|
||||
this.pendingRecords.get(replacementId)?.records.push(...records)
|
||||
this.events.push(`append:${replacementId}`)
|
||||
}
|
||||
|
||||
async finishDocumentReplacement(
|
||||
replacementId: string,
|
||||
documentId: string
|
||||
): Promise<void> {
|
||||
const pending = this.pendingRecords.get(replacementId)
|
||||
if (!pending) {
|
||||
throw new Error('Missing pending replacement')
|
||||
}
|
||||
this.records.set(documentId, pending.records)
|
||||
this.errors.delete(documentId)
|
||||
this.pendingRecords.delete(replacementId)
|
||||
this.events.push(`finish:${documentId}`)
|
||||
}
|
||||
|
||||
async discardDocumentReplacement(
|
||||
replacementId: string
|
||||
): Promise<void> {
|
||||
this.pendingRecords.delete(replacementId)
|
||||
this.events.push(`discard:${replacementId}`)
|
||||
}
|
||||
|
||||
async recordDocumentError(
|
||||
documentId: string,
|
||||
_provider: string,
|
||||
_model: string,
|
||||
error: string
|
||||
): Promise<void> {
|
||||
this.errors.set(documentId, error)
|
||||
}
|
||||
}
|
||||
|
||||
function provider(
|
||||
embed: EmbeddingIndexProvider['embed']
|
||||
): EmbeddingIndexProvider {
|
||||
return {
|
||||
provider: 'openai-compatible',
|
||||
model: 'embed-v2',
|
||||
fingerprint: 'openai-compatible:https://safe.invalid:embed-v2',
|
||||
embed
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmbeddingIndexCoordinator', () => {
|
||||
it('performs a real embedding request for diagnostics', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const embed = vi.fn(async () => [[0.25, 0.5, 0.75]])
|
||||
const times = [100, 137]
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
now: () => times.shift() ?? 137
|
||||
})
|
||||
|
||||
await expect(coordinator.diagnose(provider(embed))).resolves.toEqual({
|
||||
status: 'available',
|
||||
provider: 'openai-compatible',
|
||||
model: 'embed-v2',
|
||||
checkedAt: 137,
|
||||
latencyMs: 37,
|
||||
dimensions: 3
|
||||
})
|
||||
expect(embed).toHaveBeenCalledWith(
|
||||
['GoodBuddy 向量模型连接测试'],
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a safe diagnostic failure instead of treating config as success', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
now: () => 100
|
||||
})
|
||||
const result = await coordinator.diagnose(
|
||||
provider(async () => {
|
||||
throw Object.assign(
|
||||
new Error('Bearer sk-secret failed with private payload'),
|
||||
{ status: 401 }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'unavailable',
|
||||
error: {
|
||||
code: 'authentication',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toContain('sk-secret')
|
||||
expect(JSON.stringify(result)).not.toContain('private payload')
|
||||
})
|
||||
|
||||
it('replaces each document atomically and persists completed progress', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
batchSize: 2,
|
||||
now: (() => {
|
||||
let value = 10
|
||||
return () => value++
|
||||
})(),
|
||||
createId: () => 'job-1'
|
||||
})
|
||||
await coordinator.initialize()
|
||||
const statuses: string[] = []
|
||||
coordinator.subscribe((status) => {
|
||||
statuses.push(status.job?.status ?? 'idle')
|
||||
})
|
||||
|
||||
const queued = coordinator.startRebuild(
|
||||
provider(async (input) =>
|
||||
input.map((text) =>
|
||||
text === 'beta' ? [0, 1] : [1, 0]
|
||||
)
|
||||
)
|
||||
)
|
||||
expect(queued.status).toBe('queued')
|
||||
|
||||
const completed = await coordinator.waitForCompletion()
|
||||
expect(completed).toMatchObject({
|
||||
status: 'completed',
|
||||
progress: { completed: 2, total: 2, percent: 100 }
|
||||
})
|
||||
expect(statuses).toContain('queued')
|
||||
expect(statuses).toContain('running')
|
||||
expect(statuses.at(-1)).toBe('completed')
|
||||
expect(repository.records.get('document-1')).toEqual([
|
||||
expect.objectContaining({ itemId: 'chunk-1', vector: [1, 0] })
|
||||
])
|
||||
expect(repository.records.get('document-2')).toEqual([
|
||||
expect.objectContaining({ itemId: 'chunk-2', vector: [0, 1] }),
|
||||
expect.objectContaining({ itemId: 'chunk-3', vector: [1, 0] })
|
||||
])
|
||||
expect(repository.events.indexOf('finish:document-1')).toBeLessThan(
|
||||
repository.events.indexOf('load:document-2')
|
||||
)
|
||||
expect(repository.pendingRecords.size).toBe(0)
|
||||
expect(repository.lastJob).toEqual(completed)
|
||||
})
|
||||
|
||||
it('keeps completed documents and records a safe error on the failed document', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
batchSize: 2,
|
||||
now: () => 20,
|
||||
createId: () => 'job-failed'
|
||||
})
|
||||
await coordinator.initialize()
|
||||
coordinator.startRebuild(
|
||||
provider(async (input) => {
|
||||
if (input.includes('beta')) {
|
||||
throw Object.assign(new Error('raw upstream token sk-private'), {
|
||||
status: 429
|
||||
})
|
||||
}
|
||||
return input.map(() => [1, 0])
|
||||
})
|
||||
)
|
||||
|
||||
const failed = await coordinator.waitForCompletion()
|
||||
expect(failed).toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'rate_limited',
|
||||
retryable: true
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(failed)).not.toContain('sk-private')
|
||||
expect(failed?.progress).toEqual({
|
||||
completed: 1,
|
||||
total: 2,
|
||||
percent: 50
|
||||
})
|
||||
expect(repository.records.get('document-1')?.[0]?.vector).toEqual([1, 0])
|
||||
expect(repository.records.get('document-2')?.[0]?.vector).toEqual([0, 1])
|
||||
expect(repository.errors.get('document-2')).toBe(
|
||||
'向量服务当前请求过多。'
|
||||
)
|
||||
expect(repository.pendingRecords.size).toBe(0)
|
||||
expect(JSON.stringify(repository.savedStatuses)).not.toContain(
|
||||
'sk-private'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps completed documents and leaves unfinished documents unchanged when cancelled', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
let receivedSignal: AbortSignal | undefined
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
now: () => 30,
|
||||
createId: () => 'job-cancelled'
|
||||
})
|
||||
await coordinator.initialize()
|
||||
coordinator.startRebuild(
|
||||
provider(
|
||||
(input, signal) => {
|
||||
if (input.includes('alpha')) {
|
||||
return Promise.resolve(input.map(() => [1, 0]))
|
||||
}
|
||||
return new Promise<number[][]>((_resolve, reject) => {
|
||||
receivedSignal = signal
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(signal.reason),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(coordinator.status().job?.status).toBe('running')
|
||||
expect(receivedSignal).toBeDefined()
|
||||
})
|
||||
|
||||
expect(coordinator.cancel('another-job')).toBe(false)
|
||||
expect(coordinator.cancel('job-cancelled')).toBe(true)
|
||||
const cancelled = await coordinator.waitForCompletion()
|
||||
|
||||
expect(receivedSignal?.aborted).toBe(true)
|
||||
expect(cancelled).toMatchObject({
|
||||
status: 'cancelled',
|
||||
completedAt: 30,
|
||||
progress: { completed: 1, total: 2, percent: 50 }
|
||||
})
|
||||
expect(cancelled).not.toHaveProperty('error')
|
||||
expect(repository.records.get('document-1')?.[0]?.vector).toEqual([1, 0])
|
||||
expect(repository.records.get('document-2')?.[0]?.vector).toEqual([0, 1])
|
||||
expect(repository.errors.has('document-2')).toBe(false)
|
||||
expect(repository.pendingRecords.size).toBe(0)
|
||||
expect(repository.lastJob).toEqual(cancelled)
|
||||
})
|
||||
|
||||
it('marks an interrupted persisted job cancelled during initialization', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
repository.lastJob = {
|
||||
id: 'interrupted-job',
|
||||
status: 'running',
|
||||
provider: 'provider',
|
||||
model: 'model',
|
||||
progress: { completed: 1, total: 2, percent: 50 },
|
||||
createdAt: 10,
|
||||
startedAt: 11
|
||||
}
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository, {
|
||||
now: () => 12
|
||||
})
|
||||
|
||||
await expect(coordinator.initialize()).resolves.toEqual({
|
||||
job: expect.objectContaining({
|
||||
id: 'interrupted-job',
|
||||
status: 'cancelled',
|
||||
completedAt: 12
|
||||
})
|
||||
})
|
||||
expect(repository.lastJob).toMatchObject({
|
||||
status: 'cancelled',
|
||||
completedAt: 12
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects overlapping rebuilds', async () => {
|
||||
const repository = new MemoryRepository()
|
||||
const coordinator = new EmbeddingIndexCoordinator(repository)
|
||||
coordinator.startRebuild(
|
||||
provider(
|
||||
(_input, signal) =>
|
||||
new Promise<number[][]>((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(() =>
|
||||
coordinator.startRebuild(provider(async () => [[1, 0]]))
|
||||
).toThrow('already active')
|
||||
coordinator.cancel()
|
||||
await coordinator.waitForCompletion()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user