history.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. /** Cold Session history pagination and live-event source. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import { Deque } from '@deepseek-ai/dsh-deque'
  4. import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
  5. import {
  6. isAppendSurfaceEvent,
  7. SessionLogOffset,
  8. SessionSeq,
  9. } from '@deepseek-ai/dsh-session'
  10. import type {
  11. SessionEvent,
  12. SessionHeader,
  13. SessionId,
  14. SessionLogOffset as SessionLogOffsetType,
  15. SessionSeqCursor,
  16. } from '@deepseek-ai/dsh-session'
  17. import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
  18. import type {} from '@deepseek-ai/dsh-subagent'
  19. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  20. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  21. import type {
  22. SessionAddress,
  23. SessionAssistantStreamFrame,
  24. SessionEventEntry,
  25. SessionFollowRequest,
  26. SessionFollowFrame,
  27. SessionHistoryRecord,
  28. SessionPage,
  29. SessionPageRequest,
  30. SessionProjectionBaseline,
  31. SessionProjectionValues,
  32. SessionWireHeader,
  33. SessionWireEvent,
  34. } from './types.ts'
  35. import { SessionAssistantStreamAccumulator } from './assistant-stream.ts'
  36. const DEFAULT_MAX_MESSAGES = 50
  37. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
  38. /** Implements cold-safe history operations delegated by the Session Controller. */
  39. export class SessionHistoryController {
  40. private readonly closeFollowers = new Set<() => void>()
  41. private readonly assistantStreams = new Map<SessionId, SessionAssistantStreamAccumulator>()
  42. /**
  43. * @param ctx - Host context carrying Session query and projection services.
  44. * @param promote - starts ordinary Session activation after snapshot delivery.
  45. */
  46. constructor(
  47. private readonly ctx: Context,
  48. private readonly promote: (observation: SessionObservation) => void,
  49. ) {
  50. ctx.on('agent/assistant-stream', ({ agent, frame }) => {
  51. let stream = this.assistantStreams.get(agent.session.id)
  52. if (stream === undefined) {
  53. stream = new SessionAssistantStreamAccumulator()
  54. this.assistantStreams.set(agent.session.id, stream)
  55. }
  56. stream.accept(frame, cursorBeforeNext(agent.session.seq))
  57. }, { global: true })
  58. ctx.on('agent/disposed', ({ agent }) => {
  59. this.assistantStreams.delete(agent.session.id)
  60. }, { global: true })
  61. ctx.effect(() => () => {
  62. for (const close of this.closeFollowers) close()
  63. this.closeFollowers.clear()
  64. }, 'session-controller.history')
  65. }
  66. /**
  67. * Read one message-aligned history page without activating an Agent.
  68. * @param request - durable address and backwards-page cursor.
  69. * @param signal - caller cancellation for persistence reads.
  70. * @returns a contiguous event page.
  71. */
  72. async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
  73. validatePageRequest(request)
  74. const throughSeq: SessionSeqCursor = request.throughSeq === -1
  75. ? -1
  76. : SessionSeq(request.throughSeq)
  77. const beforeSeq = request.beforeSeq === undefined
  78. ? undefined
  79. : SessionLogOffset(request.beforeSeq)
  80. using source = await this.sourceFor(request.address, signal, false)
  81. signal.throwIfAborted()
  82. const sourceLog = source.events
  83. const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1
  84. if (throughSeq > sourceCursor) {
  85. throw new RemoteError(
  86. 'gateway/bad-request',
  87. `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`,
  88. {},
  89. )
  90. }
  91. /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
  92. if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) {
  93. throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {})
  94. }
  95. const page = paginate(
  96. sourceLog,
  97. beforeSeq,
  98. request.maxMessages ?? DEFAULT_MAX_MESSAGES,
  99. throughSeq,
  100. )
  101. const records = pageRecords(page.events)
  102. return {
  103. records,
  104. hasMore: page.hasMore,
  105. }
  106. }
  107. /**
  108. * Follow events appended after an initial cursor on one durable address.
  109. * @param request - durable address and last committed sequence already held by the caller.
  110. * @param signal - stream cancellation owned by the Remote carrier.
  111. * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
  112. */
  113. async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
  114. validateFollowRequest(request)
  115. const { address } = request
  116. const target = addressId(address)
  117. const buffered = new Deque<
  118. | { readonly type: 'event'; readonly event: SessionEvent }
  119. | {
  120. readonly type: 'assistant-stream'
  121. readonly frame: SessionAssistantStreamFrame
  122. readonly ordinal: number
  123. }
  124. >()
  125. let snapshotCursor: SessionSeqCursor | undefined
  126. let assistantStreamOrdinal = 0
  127. let wake: (() => void) | undefined
  128. const notify = (): void => {
  129. const resume = wake
  130. wake = undefined
  131. resume?.()
  132. }
  133. const follower = { closed: false }
  134. const close = (): void => {
  135. follower.closed = true
  136. notify()
  137. }
  138. this.closeFollowers.add(close)
  139. const disposeEvent = this.ctx.on('session/event', (session, event) => {
  140. if (session.id !== target) return
  141. buffered.pushBack({ type: 'event', event })
  142. notify()
  143. }, { global: true })
  144. const disposeCreated = this.ctx.on('session/created', (session) => {
  145. if (session.id !== target) return
  146. // Constructor seed events have no session/event notification. Normally
  147. // only the end-seed suffix is new; if persistence advanced after the
  148. // opening observation, replay everything beyond that snapshot cursor.
  149. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  150. const suffix = session.snapshotEvents(snapshotCursor === undefined
  151. ? session.firstLiveSeq
  152. : SessionLogOffset(snapshotCursor + 1))
  153. for (let index = suffix.length - 1; index >= 0; index -= 1) {
  154. buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent })
  155. }
  156. notify()
  157. }, { global: true })
  158. const disposeAssistantStream = request.assistantStream !== true
  159. ? undefined
  160. : this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
  161. if (agent.session.id !== target) return
  162. buffered.pushBack({
  163. type: 'assistant-stream',
  164. frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)),
  165. ordinal: ++assistantStreamOrdinal,
  166. })
  167. notify()
  168. }, { global: true })
  169. const onAbort = (): void => { notify() }
  170. signal.addEventListener('abort', onAbort, { once: true })
  171. try {
  172. using source = await this.sourceFor(address, signal, true)
  173. const events = source.events
  174. signal.throwIfAborted()
  175. const cursor = source.cursor
  176. snapshotCursor = cursor
  177. const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
  178. const assistantStream = request.assistantStream === true
  179. ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }
  180. : undefined
  181. // The accumulator snapshot and this watermark are synchronous. Frames
  182. // through the cut are represented or superseded by that baseline,
  183. // including larger revisions from a retired Agent; later revision
  184. // resets reach Client continuity validation.
  185. const assistantStreamOrdinalCut = assistantStreamOrdinal
  186. yield {
  187. type: 'snapshot',
  188. header: wireHeader(source.header),
  189. cursor,
  190. records: pageRecords(page.events),
  191. hasMore: page.hasMore,
  192. projections: source.projections === undefined
  193. ? { asOfSeq: cursor, values: {} }
  194. : projectionBlock(source.projections),
  195. ...assistantStream === undefined ? {} : { assistantStream },
  196. }
  197. if (address.kind === 'session' && source.source === 'prepared') {
  198. const promotion = source.retain()
  199. try {
  200. this.promote(promotion)
  201. } catch (error: unknown) {
  202. promotion[Symbol.dispose]()
  203. throw error
  204. }
  205. }
  206. let nextOffset = SessionLogOffset(cursor + 1)
  207. while (!follower.closed && !signal.aborted) {
  208. const item = buffered.popFront()
  209. if (item === undefined) {
  210. await new Promise<void>((resolve) => { wake = resolve })
  211. continue
  212. }
  213. if (item.type === 'assistant-stream') {
  214. if (item.ordinal > assistantStreamOrdinalCut) {
  215. yield { type: 'assistant-stream', frame: item.frame }
  216. }
  217. continue
  218. }
  219. const expectedSeq = SessionSeq(nextOffset)
  220. if (item.event.seq < expectedSeq) continue
  221. if (item.event.seq !== expectedSeq) {
  222. throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {})
  223. }
  224. nextOffset = SessionLogOffset(nextOffset + 1)
  225. yield entryFor(item.event)
  226. }
  227. } finally {
  228. this.closeFollowers.delete(close)
  229. signal.removeEventListener('abort', onAbort)
  230. disposeCreated()
  231. disposeEvent()
  232. disposeAssistantStream?.()
  233. }
  234. }
  235. private async sourceFor(
  236. address: SessionAddress,
  237. signal: AbortSignal,
  238. withProjections: boolean,
  239. ): Promise<SessionObservation> {
  240. const sessionId = addressId(address)
  241. try {
  242. const observation = await this.ctx.sessionQuery.observeSession(sessionId, {
  243. signal,
  244. projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',
  245. })
  246. if (observation.header.cwd === undefined) {
  247. observation[Symbol.dispose]()
  248. rejectNotFound(address)
  249. }
  250. try {
  251. validateAddress(
  252. address,
  253. observation.header,
  254. observation.inheritedEventCount,
  255. observation.projections,
  256. )
  257. } catch (error: unknown) {
  258. observation[Symbol.dispose]()
  259. throw error
  260. }
  261. return observation
  262. } catch (error: unknown) {
  263. if (error instanceof SessionQueryError
  264. && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address)
  265. throw error
  266. }
  267. }
  268. }
  269. function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor {
  270. return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1)
  271. }
  272. function wireAssistantStreamFrame(
  273. frame: AssistantStreamFrame,
  274. durableCursor: SessionSeqCursor,
  275. ): SessionAssistantStreamFrame {
  276. if (frame.type === 'start') return { ...frame, startedAfterSeq: durableCursor }
  277. if (frame.type === 'end') return frame
  278. return {
  279. ...frame,
  280. chunk: frame.chunk as JsonValue,
  281. }
  282. }
  283. function projectionBlock(
  284. snapshot: NonNullable<SessionObservation['projections']>,
  285. ): SessionProjectionBaseline {
  286. return {
  287. asOfSeq: snapshot.asOfSeq,
  288. // Projection definitions validate whole JSON values before snapshot publication.
  289. values: snapshot.values as SessionProjectionValues,
  290. }
  291. }
  292. function validatePageRequest(request: SessionPageRequest): void {
  293. if (!Number.isSafeInteger(request.throughSeq)
  294. || request.throughSeq < -1
  295. || Object.is(request.throughSeq, -0)) {
  296. throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
  297. }
  298. if (request.beforeSeq !== undefined
  299. && (!Number.isSafeInteger(request.beforeSeq)
  300. || request.beforeSeq < 0
  301. || Object.is(request.beforeSeq, -0))) {
  302. throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
  303. }
  304. if (request.maxMessages !== undefined
  305. && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
  306. throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
  307. }
  308. }
  309. function validateFollowRequest(request: SessionFollowRequest): void {
  310. if (request.maxMessages !== undefined
  311. && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
  312. throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
  313. }
  314. }
  315. function addressId(address: SessionAddress): SessionId {
  316. return address.kind === 'session' ? address.sessionId : address.childSessionId
  317. }
  318. function validateAddress(
  319. address: SessionAddress,
  320. header: SessionHeader,
  321. inheritedEventCount: SessionLogOffsetType,
  322. projections: SessionObservation['projections'],
  323. ): void {
  324. if (address.kind === 'session') {
  325. if (header.origin === 'subagent') {
  326. throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
  327. reason: 'use subagent delivery for this child session',
  328. })
  329. }
  330. return
  331. }
  332. if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
  333. throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
  334. childSessionId: address.childSessionId,
  335. })
  336. }
  337. const identity = projections?.values.subagent
  338. if (identity === null) {
  339. throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
  340. parentSessionId: address.parentSessionId,
  341. childSessionId: address.childSessionId,
  342. reason: 'corrupt',
  343. })
  344. }
  345. if (identity === undefined || identity.seq < inheritedEventCount) {
  346. throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
  347. parentSessionId: address.parentSessionId,
  348. childSessionId: address.childSessionId,
  349. reason: 'unsupported',
  350. })
  351. }
  352. if (identity.mode !== address.mode) {
  353. throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
  354. childSessionId: address.childSessionId,
  355. })
  356. }
  357. }
  358. function rejectNotFound(address: SessionAddress): never {
  359. if (address.kind === 'session') {
  360. throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
  361. }
  362. throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
  363. parentSessionId: address.parentSessionId,
  364. childSessionId: address.childSessionId,
  365. })
  366. }
  367. function paginate(
  368. events: readonly SessionEvent[],
  369. beforeSeq: SessionLogOffsetType | undefined,
  370. maxMessages: number,
  371. throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
  372. ): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
  373. const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
  374. let count = 0
  375. let cut = SessionLogOffset(0)
  376. for (let index = end - 1; index >= 0; index--) {
  377. const event = events[index] as SessionEvent
  378. if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
  379. count++
  380. const sources = event.sourceEventSeqs
  381. let groupStart = event.seq
  382. if (sources !== undefined) {
  383. for (const source of sources) {
  384. if (source < groupStart) groupStart = source
  385. }
  386. }
  387. if (count >= maxMessages) {
  388. cut = SessionLogOffset(groupStart)
  389. break
  390. }
  391. }
  392. return { events: events.slice(cut, end), hasMore: cut > 0 }
  393. }
  394. /** Translate current logical Session metadata to the browser wire. */
  395. function wireHeader(header: SessionHeader): SessionWireHeader {
  396. return { ...header }
  397. }
  398. function entryFor(event: SessionEvent): SessionEventEntry {
  399. return {
  400. type: 'event',
  401. // Session.append validates and freezes event data as JSON before publication.
  402. event: event as unknown as SessionWireEvent,
  403. }
  404. }
  405. /** Encode one bounded logical page without changing its pagination cut. */
  406. function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
  407. return events.map(entryFor)
  408. }