list.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /** Cold-safe Session list and search projection. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import type {} from '@deepseek-ai/dsh-agent-presets'
  4. import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
  5. import { SessionLogOffset } from '@deepseek-ai/dsh-session'
  6. import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  7. import type {} from '@deepseek-ai/dsh-session-projection'
  8. import type {} from '@deepseek-ai/dsh-session-projection-cache'
  9. import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
  10. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  11. import { z } from 'zod'
  12. import {
  13. SESSION_SEARCH_RESULT_LIMIT,
  14. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  15. } from './types.ts'
  16. import type {
  17. SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem,
  18. SessionSearchValue, SessionSummary,
  19. } from './types.ts'
  20. const SEARCH_PROVIDER_CALL_LIMIT = 100
  21. const SESSION_SEARCH_QUERY_MAX_CHARS = 500
  22. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
  23. const sessionListMetadataSchema: z.ZodType<SessionListMetadata> = z.object({
  24. blank: z.boolean(),
  25. lastPromptAt: z.number().nullable(),
  26. })
  27. const imageLimitsSchema = z.object({
  28. maxImageBytes: z.number().int().positive(),
  29. maxImagesPerMessage: z.number().int().positive(),
  30. maxMessageImageBytes: z.number().int().positive(),
  31. maxImagePixels: z.number().int().positive(),
  32. maxImageDimension: z.number().int().positive(),
  33. mediaTypes: z.array(z.string()),
  34. }) as unknown as z.ZodType<ImageAttachmentLimits>
  35. /**
  36. * Advance the Session-list metadata projection by one committed event.
  37. * @param state - metadata before the event.
  38. * @param event - next committed Session event.
  39. * @returns the original or advanced metadata value.
  40. */
  41. export function applySessionListMetadata(
  42. state: SessionListMetadata,
  43. event: SessionEvent,
  44. ): SessionListMetadata {
  45. const blank = state.blank && event.type !== 'turn/start'
  46. const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
  47. ? event.time
  48. : state.lastPromptAt
  49. return blank === state.blank && lastPromptAt === state.lastPromptAt
  50. ? state
  51. : { blank, lastPromptAt }
  52. }
  53. /**
  54. * Return the longest prefix containing at most `maximum` Unicode code points.
  55. * @param value - source text.
  56. * @param maximum - maximum number of Unicode code points.
  57. * @returns the source text or its longest allowed prefix.
  58. */
  59. export function truncateUnicodeCodePoints(value: string, maximum: number): string {
  60. let count = 0
  61. let end = 0
  62. for (const codePoint of value) {
  63. if (count === maximum) return value.slice(0, end)
  64. count++
  65. end += codePoint.length
  66. }
  67. return value
  68. }
  69. /** Owns list projection registration, bounded cold summaries, and authorized search. */
  70. export class ApiSessionList {
  71. /** @param ctx - Host context carrying Session, query, persistence, and projection services. */
  72. constructor(private readonly ctx: Context) {
  73. ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
  74. key: 'sessionListMetadata',
  75. stateSchema: sessionListMetadataSchema,
  76. init: () => ({ blank: true, lastPromptAt: null }),
  77. apply: applySessionListMetadata,
  78. wire: { viewSchema: sessionListMetadataSchema, view: state => state },
  79. stateVersion: 1,
  80. })
  81. ctx.inject(['attachments'], (attachmentCtx) => {
  82. ctx.sessionProjections.register<'imageLimits', null>({
  83. key: 'imageLimits',
  84. stateSchema: z.null(),
  85. init: () => null,
  86. apply: state => state,
  87. wire: {
  88. viewSchema: imageLimitsSchema,
  89. view: () => attachmentCtx.attachments.imageLimits,
  90. },
  91. stateVersion: 1,
  92. })
  93. })
  94. }
  95. /**
  96. * Build one current attached-Session summary.
  97. * @param session - attached Session to summarize.
  98. * @returns current list metadata and available projections.
  99. */
  100. summaryFor(session: Session): SessionSummary {
  101. const projections = this.projectionsFor(session.header, session)
  102. const metadata = projections?.values.sessionListMetadata
  103. return {
  104. sessionId: session.id,
  105. updatedAt: updatedAt(session.header, metadata),
  106. running: this.ctx.agents.get(session.id)?.status === 'running',
  107. blank: metadata?.blank ?? session.seq === 0,
  108. ...listFields(session.header),
  109. ...(projections === undefined ? {} : { projections }),
  110. }
  111. }
  112. /**
  113. * Read every visible attached and persisted Session without activating an Agent.
  114. * @param signal - optional cancellation for persistence reads.
  115. * @returns visible Session summaries ordered by activity.
  116. */
  117. async list(signal?: AbortSignal): Promise<SessionSummary[]> {
  118. signal?.throwIfAborted()
  119. const records = await this.ctx.sessionQuery.listSessions(signal)
  120. signal?.throwIfAborted()
  121. const items: SessionSummary[] = []
  122. const cold: SessionHeader[] = []
  123. for (const record of records) {
  124. const live = this.ctx.sessions.get(record.header.id)
  125. if (live !== undefined) {
  126. items.push(this.summaryFor(live))
  127. continue
  128. }
  129. if (record.header.cwd === undefined) continue
  130. cold.push(record.header)
  131. }
  132. for (const header of cold) items.push(this.summarizeCold(header))
  133. items.sort((left, right) => right.updatedAt - left.updatedAt)
  134. return items
  135. }
  136. private summarizeCold(header: SessionHeader): SessionSummary {
  137. const projections = this.projectionsFor(header, undefined)
  138. const metadata = projections?.values.sessionListMetadata
  139. return {
  140. sessionId: header.id,
  141. updatedAt: updatedAt(header, metadata),
  142. running: false,
  143. // A large, metadata-less, or inaccessible cache miss remains unknown and visible.
  144. blank: metadata?.blank ?? false,
  145. ...listFields(header),
  146. ...(projections === undefined ? {} : { projections }),
  147. }
  148. }
  149. /**
  150. * Search current visible message content without activating any matching Session.
  151. * @param query - literal message-content query.
  152. * @param signal - cancellation for list and search reads.
  153. * @returns authorized bounded Session search results.
  154. */
  155. async search(query: string, signal: AbortSignal): Promise<SessionSearchValue> {
  156. const normalizedQuery = normalizeSearchQuery(query)
  157. signal.throwIfAborted()
  158. const provider = this.ctx.get('sessionQuery')
  159. if (provider === undefined) {
  160. throw new RemoteError(
  161. 'gateway/internal',
  162. 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
  163. {},
  164. )
  165. }
  166. try {
  167. const visible = await provider.listSessions(signal)
  168. signal.throwIfAborted()
  169. const visibleIds = new Set(visible
  170. .filter(record => record.header.cwd !== undefined)
  171. .map(record => record.header.id))
  172. if (visibleIds.size === 0) return { items: [], hasMore: false }
  173. const authorized: SessionSearchItem[] = []
  174. const acceptedIds = new Set<SessionId>()
  175. const seenCursors = new Set<SessionSearchCursor>()
  176. let cursor: SessionSearchCursor | undefined
  177. let providerCalls = 0
  178. let pageLimit = SESSION_SEARCH_RESULT_LIMIT
  179. while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
  180. signal.throwIfAborted()
  181. if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) {
  182. throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`)
  183. }
  184. providerCalls++
  185. const requestedCursor = cursor
  186. const requestedLimit = pageLimit
  187. let page
  188. try {
  189. page = await provider.searchSessions({
  190. query: normalizedQuery,
  191. eventFilters: [
  192. { kind: 'type', values: ['user/message', 'assistant/message'] },
  193. { kind: 'surface', values: ['current'] },
  194. ],
  195. limit: requestedLimit,
  196. ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
  197. }, { signal })
  198. signal.throwIfAborted()
  199. } catch (error: unknown) {
  200. signal.throwIfAborted()
  201. if (requestedCursor === undefined
  202. && error instanceof SessionQueryError
  203. && error.code === 'SESSION_QUERY_INVALID_LIMIT'
  204. && requestedLimit > 1) {
  205. pageLimit = Math.max(1, Math.floor(requestedLimit / 2))
  206. continue
  207. }
  208. if (requestedCursor !== undefined
  209. && error instanceof SessionQueryError
  210. && error.code === 'SESSION_QUERY_STALE_CURSOR') {
  211. authorized.length = 0
  212. acceptedIds.clear()
  213. seenCursors.clear()
  214. cursor = undefined
  215. continue
  216. }
  217. throw error
  218. }
  219. if (page.items.length > requestedLimit) {
  220. throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`)
  221. }
  222. for (const hit of page.items) {
  223. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
  224. if (!visibleIds.has(hit.header.id)
  225. || hit.bestMatch.sessionId !== hit.header.id
  226. || hit.bestMatch.surface !== 'current'
  227. || !MESSAGE_TYPES.has(hit.bestMatch.type)
  228. || acceptedIds.has(hit.header.id)) continue
  229. acceptedIds.add(hit.header.id)
  230. authorized.push({
  231. sessionId: hit.header.id,
  232. snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
  233. })
  234. }
  235. if (page.nextCursor !== undefined) {
  236. if (seenCursors.has(page.nextCursor)) {
  237. throw new Error('session search provider repeated a continuation cursor')
  238. }
  239. seenCursors.add(page.nextCursor)
  240. }
  241. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break
  242. cursor = page.nextCursor
  243. }
  244. return {
  245. items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
  246. hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
  247. }
  248. } catch (error: unknown) {
  249. signal.throwIfAborted()
  250. if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
  251. throw new RemoteError('gateway/cancelled', 'session search was aborted', {})
  252. }
  253. throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {})
  254. }
  255. }
  256. private projectionsFor(
  257. header: SessionHeader,
  258. session: Session | undefined,
  259. ): SessionProjectionHints | undefined {
  260. try {
  261. const cache = this.ctx.get('sessionProjectionCache')
  262. const block = session === undefined
  263. ? header.isSeeded
  264. ? undefined
  265. : cache?.cachedSnapshot(header, SessionLogOffset(0))
  266. ?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0))
  267. : this.ctx.sessionProjections.cachedSnapshot(session)
  268. return block !== undefined && Object.keys(block.values).length > 0
  269. ? {
  270. asOfSeq: block.asOfSeq,
  271. // Listing hints contain every currently cached wire value but remain
  272. // partial: missing cells and cache rows are never materialized here.
  273. values: block.values as SessionProjectionValues,
  274. }
  275. : undefined
  276. } catch (error) {
  277. this.ctx.logger.warn(
  278. `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`,
  279. )
  280. return undefined
  281. }
  282. }
  283. }
  284. function normalizeSearchQuery(query: string): string {
  285. const normalized = query.trim()
  286. if (normalized.length === 0) {
  287. throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {})
  288. }
  289. if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
  290. throw new RemoteError(
  291. 'gateway/bad-request',
  292. `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
  293. {},
  294. )
  295. }
  296. if (normalized.includes('\0')) {
  297. throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {})
  298. }
  299. return normalized
  300. }
  301. function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
  302. return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
  303. }
  304. function listFields(header: SessionHeader): {
  305. readonly parentSessionId?: SessionId
  306. readonly origin?: 'subagent'
  307. readonly cwd?: string
  308. } {
  309. return {
  310. ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
  311. ...(header.origin === undefined ? {} : { origin: header.origin }),
  312. ...(header.cwd === undefined ? {} : { cwd: header.cwd }),
  313. }
  314. }