Files
deepseek-harness/packages/api/session-controller/src/list.ts
T

330 lines
12 KiB
TypeScript

/** Cold-safe Session list and search projection. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from './types.ts'
import type {
SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem,
SessionSearchValue, SessionSummary,
} from './types.ts'
const SEARCH_PROVIDER_CALL_LIMIT = 100
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
const sessionListMetadataSchema: z.ZodType<SessionListMetadata> = z.object({
blank: z.boolean(),
lastPromptAt: z.number().nullable(),
})
const imageLimitsSchema = z.object({
maxImageBytes: z.number().int().positive(),
maxImagesPerMessage: z.number().int().positive(),
maxMessageImageBytes: z.number().int().positive(),
maxImagePixels: z.number().int().positive(),
maxImageDimension: z.number().int().positive(),
mediaTypes: z.array(z.string()),
}) as unknown as z.ZodType<ImageAttachmentLimits>
/**
* Advance the Session-list metadata projection by one committed event.
* @param state - metadata before the event.
* @param event - next committed Session event.
* @returns the original or advanced metadata value.
*/
export function applySessionListMetadata(
state: SessionListMetadata,
event: SessionEvent,
): SessionListMetadata {
const blank = state.blank && event.type !== 'turn/start'
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
? event.time
: state.lastPromptAt
return blank === state.blank && lastPromptAt === state.lastPromptAt
? state
: { blank, lastPromptAt }
}
/**
* Return the longest prefix containing at most `maximum` Unicode code points.
* @param value - source text.
* @param maximum - maximum number of Unicode code points.
* @returns the source text or its longest allowed prefix.
*/
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
let count = 0
let end = 0
for (const codePoint of value) {
if (count === maximum) return value.slice(0, end)
count++
end += codePoint.length
}
return value
}
/** Owns list projection registration, bounded cold summaries, and authorized search. */
export class ApiSessionList {
/** @param ctx - Host context carrying Session, query, persistence, and projection services. */
constructor(private readonly ctx: Context) {
ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
stateSchema: sessionListMetadataSchema,
init: () => ({ blank: true, lastPromptAt: null }),
apply: applySessionListMetadata,
wire: { viewSchema: sessionListMetadataSchema, view: state => state },
stateVersion: 1,
})
ctx.inject(['attachments'], (attachmentCtx) => {
ctx.sessionProjections.register<'imageLimits', null>({
key: 'imageLimits',
stateSchema: z.null(),
init: () => null,
apply: state => state,
wire: {
viewSchema: imageLimitsSchema,
view: () => attachmentCtx.attachments.imageLimits,
},
stateVersion: 1,
})
})
}
/**
* Build one current attached-Session summary.
* @param session - attached Session to summarize.
* @returns current list metadata and available projections.
*/
summaryFor(session: Session): SessionSummary {
const projections = this.projectionsFor(session.header, session)
const metadata = projections?.values.sessionListMetadata
return {
sessionId: session.id,
updatedAt: updatedAt(session.header, metadata),
running: this.ctx.agents.get(session.id)?.status === 'running',
blank: metadata?.blank ?? session.seq === 0,
...listFields(session.header),
...(projections === undefined ? {} : { projections }),
}
}
/**
* Read every visible attached and persisted Session without activating an Agent.
* @param signal - optional cancellation for persistence reads.
* @returns visible Session summaries ordered by activity.
*/
async list(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted()
const records = await this.ctx.sessionQuery.listSessions(signal)
signal?.throwIfAborted()
const items: SessionSummary[] = []
const cold: SessionHeader[] = []
for (const record of records) {
const live = this.ctx.sessions.get(record.header.id)
if (live !== undefined) {
items.push(this.summaryFor(live))
continue
}
if (record.header.cwd === undefined) continue
cold.push(record.header)
}
for (const header of cold) items.push(this.summarizeCold(header))
items.sort((left, right) => right.updatedAt - left.updatedAt)
return items
}
private summarizeCold(header: SessionHeader): SessionSummary {
const projections = this.projectionsFor(header, undefined)
const metadata = projections?.values.sessionListMetadata
return {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
running: false,
// A large, metadata-less, or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
...listFields(header),
...(projections === undefined ? {} : { projections }),
}
}
/**
* Search current visible message content without activating any matching Session.
* @param query - literal message-content query.
* @param signal - cancellation for list and search reads.
* @returns authorized bounded Session search results.
*/
async search(query: string, signal: AbortSignal): Promise<SessionSearchValue> {
const normalizedQuery = normalizeSearchQuery(query)
signal.throwIfAborted()
const provider = this.ctx.get('sessionQuery')
if (provider === undefined) {
throw new RemoteError(
'gateway/internal',
'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
{},
)
}
try {
const visible = await provider.listSessions(signal)
signal.throwIfAborted()
const visibleIds = new Set(visible
.filter(record => record.header.cwd !== undefined)
.map(record => record.header.id))
if (visibleIds.size === 0) return { items: [], hasMore: false }
const authorized: SessionSearchItem[] = []
const acceptedIds = new Set<SessionId>()
const seenCursors = new Set<SessionSearchCursor>()
let cursor: SessionSearchCursor | undefined
let providerCalls = 0
let pageLimit = SESSION_SEARCH_RESULT_LIMIT
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
signal.throwIfAborted()
if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) {
throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`)
}
providerCalls++
const requestedCursor = cursor
const requestedLimit = pageLimit
let page
try {
page = await provider.searchSessions({
query: normalizedQuery,
eventFilters: [
{ kind: 'type', values: ['user/message', 'assistant/message'] },
{ kind: 'surface', values: ['current'] },
],
limit: requestedLimit,
...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
}, { signal })
signal.throwIfAborted()
} catch (error: unknown) {
signal.throwIfAborted()
if (requestedCursor === undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_INVALID_LIMIT'
&& requestedLimit > 1) {
pageLimit = Math.max(1, Math.floor(requestedLimit / 2))
continue
}
if (requestedCursor !== undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_STALE_CURSOR') {
authorized.length = 0
acceptedIds.clear()
seenCursors.clear()
cursor = undefined
continue
}
throw error
}
if (page.items.length > requestedLimit) {
throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`)
}
for (const hit of page.items) {
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
if (!visibleIds.has(hit.header.id)
|| hit.bestMatch.sessionId !== hit.header.id
|| hit.bestMatch.surface !== 'current'
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|| acceptedIds.has(hit.header.id)) continue
acceptedIds.add(hit.header.id)
authorized.push({
sessionId: hit.header.id,
snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
})
}
if (page.nextCursor !== undefined) {
if (seenCursors.has(page.nextCursor)) {
throw new Error('session search provider repeated a continuation cursor')
}
seenCursors.add(page.nextCursor)
}
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break
cursor = page.nextCursor
}
return {
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
}
} catch (error: unknown) {
signal.throwIfAborted()
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
throw new RemoteError('gateway/cancelled', 'session search was aborted', {})
}
throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {})
}
}
private projectionsFor(
header: SessionHeader,
session: Session | undefined,
): SessionProjectionHints | undefined {
try {
const cache = this.ctx.get('sessionProjectionCache')
const block = session === undefined
? header.isSeeded
? undefined
: cache?.cachedSnapshot(header, SessionLogOffset(0))
?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0))
: this.ctx.sessionProjections.cachedSnapshot(session)
return block !== undefined && Object.keys(block.values).length > 0
? {
asOfSeq: block.asOfSeq,
// Listing hints contain every currently cached wire value but remain
// partial: missing cells and cache rows are never materialized here.
values: block.values as SessionProjectionValues,
}
: undefined
} catch (error) {
this.ctx.logger.warn(
`api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`,
)
return undefined
}
}
}
function normalizeSearchQuery(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {})
}
if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
throw new RemoteError(
'gateway/bad-request',
`session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
{},
)
}
if (normalized.includes('\0')) {
throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {})
}
return normalized
}
function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
function listFields(header: SessionHeader): {
readonly parentSessionId?: SessionId
readonly origin?: 'subagent'
readonly cwd?: string
} {
return {
...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
...(header.origin === undefined ? {} : { origin: header.origin }),
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
}
}