Files
deepseek-harness/packages/session-query/session-query/src/provider.ts

309 lines
13 KiB
TypeScript

/** Search-provider selection, synchronization, pagination, and cancellation. */
import type { Context } from 'cordis'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionTextExtractors } from './extraction.ts'
import type { PersistenceView, SessionCorpus } from './corpus.ts'
import type {
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchRequest,
SessionEventSearchSpec,
SessionQueryExecContext,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchRequest,
SessionSearchSpec,
} from './types.ts'
import type { Config } from './config.ts'
import { SessionQueryError } from './config.ts'
import { filterEventResults, filterSessionResults } from './filters.ts'
interface ProviderState {
provider: SessionSearchProvider
active: boolean
chain: Promise<void>
liveIds: Set<SessionId>
}
/** Coordinates one selected provider against live and persisted corpus layers. */
export class SessionProviderCoordinator {
private readonly _configuredProviderId: string | undefined
private readonly _defaultLimit: number
private readonly _maxLimit: number
private readonly _providers = new Map<string, ProviderState>()
constructor(
config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>,
private readonly _corpus: () => SessionCorpus,
private readonly _extractors: SessionTextExtractors,
) {
this._configuredProviderId = config.searchProvider
this._defaultLimit = config.defaultLimit
this._maxLimit = config.maxLimit
}
/**
* Register one effect-scoped provider.
* @param ctx - contributing caller context.
* @param provider - provider implementation.
* @returns disposer for the registration.
*/
register(ctx: Context, provider: SessionSearchProvider): () => void {
if (this._providers.has(provider.id)) {
throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER')
}
const state: ProviderState = {
provider,
active: true,
chain: Promise.resolve(),
liveIds: new Set(),
}
const dispose = ctx.effect(function* (this: SessionProviderCoordinator) {
this._providers.set(provider.id, state)
yield () => {
state.active = false
this._providers.delete(provider.id)
}
}.bind(this), 'sessionQuery.registerSearchProvider()')
return () => void dispose()
}
/**
* Search and group the complete logical corpus.
* @param request - normalized provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked session page.
*/
async searchSessions(
request: SessionSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeSessionSearch(request)
const work = this._runFullSearch(state, async () => {
if (exec?.signal?.aborted) throw aborted()
const result = await state.provider.searchSessions(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
})
return waitFor(work, exec?.signal)
}
/**
* Search events within one logical session.
* @param request - target and provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked event page.
*/
async searchEvents(
request: SessionEventSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeEventSearch(request)
const query = async (): Promise<SessionSearchPage<SessionEventSearchHit>> => {
if (exec?.signal?.aborted) throw aborted()
const result = await state.provider.searchEvents(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
}
const live = this._corpus().getLive(request.sessionId)
let work: Promise<SessionSearchPage<SessionEventSearchHit>>
if (live !== undefined) {
work = this._runLiveSearch(state, live, query)
} else {
const persistence = await this._corpus().persistenceView()
if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) {
throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
work = this._runFullSearch(state, query)
}
return waitFor(work, exec?.signal)
}
private _runFullSearch<T>(state: ProviderState, query: () => Promise<T>): Promise<T> {
const liveSessions = this._corpus().listLive()
return this._serialize(state, async () => {
await this._synchronize(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
if (!state.active) return
const persistence = await this._corpus().persistenceView()
if (persistence === undefined) {
await state.provider.setPersistedActive(false)
} else {
await this._syncPersisted(state, persistence)
}
await this._replaceLiveCorpus(state, liveSessions)
})
return query()
})
}
private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise<void> {
await state.provider.setPersistedActive(false)
const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint]))
for (const header of persistence.headers) {
const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id))
if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot)
inventory.delete(header.id)
}
for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId)
await state.provider.setPersistedActive(true)
}
private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise<void> {
const liveIds = new Set(sessions.map(session => session.id))
for (const staleId of state.liveIds) {
if (!liveIds.has(staleId)) await state.provider.removeLive(staleId)
}
for (const session of sessions) {
await state.provider.replaceLive(this._snapshotLive(session))
}
state.liveIds = liveIds
}
private _runLiveSearch<T>(state: ProviderState, session: Session, query: () => Promise<T>): Promise<T> {
let snapshot: ReturnType<SessionTextExtractors['buildSnapshot']>
try {
snapshot = this._snapshotLive(session)
} catch (error: unknown) {
return Promise.reject(this._synchronizationError(state, error))
}
return this._serialize(state, async () => {
await this._synchronize(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
if (!state.active) return
await state.provider.replaceLive(snapshot)
state.liveIds.add(session.id)
})
return query()
})
}
private _snapshotLive(session: Session): ReturnType<SessionTextExtractors['buildSnapshot']> {
return this._extractors.buildSnapshot(this._corpus().snapshotLive(session))
}
/** Serialize reconciliation and its provider query as one stable transaction. */
private _serialize<T>(state: ProviderState, operation: () => Promise<T>): Promise<T> {
const next = state.chain.then(operation, operation)
state.chain = next.then(() => undefined, () => undefined)
return next
}
/** Translate only derived-index update failures, never provider query failures. */
private async _synchronize(state: ProviderState, operation: () => Promise<void>): Promise<void> {
try {
await operation()
} catch (error: unknown) {
throw this._synchronizationError(state, error)
}
}
private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError {
/* v8 ignore next -- service-created typed synchronization errors pass through unchanged */
if (error instanceof SessionQueryError) return error
return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error })
}
private _resolveProvider(): ProviderState {
if (this._configuredProviderId !== undefined) {
const state = this._providers.get(this._configuredProviderId)
if (state === undefined) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')
}
if (!state.provider.status().available) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')
}
return state
}
const usable = [...this._providers.values()].filter(state => state.provider.status().available)
const [single] = usable
if (single === undefined) {
throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE')
}
if (usable.length > 1) {
throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS')
}
return single
}
private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterSessionResults<SessionRecord>([], request.sessionFilters ?? [])
filterEventResults<SessionEventRecord>([], request.eventFilters ?? [])
return { ...request, query, limit }
}
private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterEventResults<SessionEventRecord>([], request.filters ?? [])
return { ...request, query, limit }
}
private _queryText(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY')
}
return normalized
}
private _limitValue(limit: number | undefined): number {
const value = limit ?? this._defaultLimit
if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) {
throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT')
}
return value
}
private _validateSearchPage<T>(state: ProviderState, page: SessionSearchPage<T>, limit: number): SessionSearchPage<T> {
if (page.providerId !== state.provider.id) {
throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR')
}
if (page.items.length > limit) {
throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR')
}
return page
}
}
function waitFor<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return work
if (signal.aborted) {
// Cancellation supersedes the caller's result, but shared work must still
// have a rejection observer when it has already failed synchronously.
void work.catch((_supersededError: unknown) => undefined)
return Promise.reject(aborted())
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => { reject(aborted()) }
signal.addEventListener('abort', onAbort, { once: true })
work.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
/* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */
reject(error instanceof Error
? error
: new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }))
},
)
})
}
function aborted(): SessionQueryError {
return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- provider update contracts reject Error instances */
return error instanceof Error ? error.message : 'unknown error'
}