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

350 lines
12 KiB
TypeScript

/** Cold Session history pagination and live-event source. */
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
SessionChunkRun,
SessionEventEntry,
SessionFollowRequest,
SessionFollowFrame,
SessionHistoryRecord,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
SessionProjectionValues,
SessionWireEvent,
} from './types.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Implements cold-safe history operations delegated by the Session Controller. */
export class SessionHistoryController {
private readonly closeFollowers = new Set<() => void>()
/**
* @param ctx - Host context carrying Session query and projection services.
* @param promote - starts ordinary Session activation after snapshot delivery.
*/
constructor(
private readonly ctx: Context,
private readonly promote: (observation: SessionObservation) => void,
) {
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
this.closeFollowers.clear()
}, 'session-controller.history')
}
/**
* Read one message-aligned history page without activating an Agent.
* @param request - durable address and backwards-page cursor.
* @param signal - caller cancellation for persistence reads.
* @returns a contiguous event page.
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
validatePageRequest(request)
using source = await this.sourceFor(request.address, signal, false)
signal.throwIfAborted()
const sourceLog = source.events
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
if (request.throughSeq > sourceCursor) {
throw new RemoteError(
'gateway/bad-request',
`session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
{},
)
}
/* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
const page = paginate(
sourceLog,
request.beforeSeq,
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
request.throughSeq,
)
const records = pageRecords(page.events)
return {
records,
hasMore: page.hasMore,
}
}
/**
* Follow events appended after an initial cursor on one durable address.
* @param request - durable address and last committed sequence already held by the caller.
* @param signal - stream cancellation owned by the Remote carrier.
* @returns a complete opening snapshot followed by gap-free event frames.
*/
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
validateFollowRequest(request)
const { address } = request
const target = addressId(address)
const buffered = new Deque<SessionEvent>()
let snapshotCursor: number | undefined
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
wake = undefined
resume?.()
}
const follower = { closed: false }
const close = (): void => {
follower.closed = true
notify()
}
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
buffered.pushBack(event)
notify()
}, { global: true })
const disposeCreated = this.ctx.on('session/created', (session) => {
if (session.id !== target) return
// Constructor seed events have no session/event notification. Normally
// only the end-seed suffix is new; if persistence advanced after the
// opening observation, replay everything beyond that snapshot cursor.
const suffix = session.events.slice(snapshotCursor === undefined
? session.firstLiveSeq
: snapshotCursor + 1)
for (let index = suffix.length - 1; index >= 0; index -= 1) {
buffered.pushFront(suffix[index] as SessionEvent)
}
notify()
}, { global: true })
const onAbort = (): void => { notify() }
signal.addEventListener('abort', onAbort, { once: true })
try {
using source = await this.sourceFor(address, signal, true)
const events = source.events
signal.throwIfAborted()
const cursor = source.cursor
snapshotCursor = cursor
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
yield {
type: 'snapshot',
header: source.header,
cursor,
records: pageRecords(page.events),
hasMore: page.hasMore,
projections: source.projections === undefined
? { asOfSeq: cursor, values: {} }
: projectionBlock(source.projections),
}
if (address.kind === 'session' && source.source === 'prepared') {
const promotion = source.retain()
try {
this.promote(promotion)
} catch (error: unknown) {
promotion[Symbol.dispose]()
throw error
}
}
let nextSeq = cursor + 1
while (!follower.closed && !signal.aborted) {
const item = buffered.popFront()
if (item === undefined) {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield entryFor(item)
}
} finally {
this.closeFollowers.delete(close)
signal.removeEventListener('abort', onAbort)
disposeCreated()
disposeEvent()
}
}
private async sourceFor(
address: SessionAddress,
signal: AbortSignal,
withProjections: boolean,
): Promise<SessionObservation> {
const sessionId = addressId(address)
try {
const observation = await this.ctx.sessionQuery.observeSession(sessionId, {
signal,
projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',
})
if (observation.header.cwd === undefined) {
observation[Symbol.dispose]()
rejectNotFound(address)
}
try {
validateAddress(address, observation.header, observation.projections)
} catch (error: unknown) {
observation[Symbol.dispose]()
throw error
}
return observation
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address)
throw error
}
}
}
function projectionBlock(
snapshot: NonNullable<SessionObservation['projections']>,
): SessionProjectionBaseline {
return {
asOfSeq: snapshot.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
values: snapshot.values as SessionProjectionValues,
}
}
function validatePageRequest(request: SessionPageRequest): void {
if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
}
if (request.beforeSeq !== undefined
&& (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
}
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
}
}
function validateFollowRequest(request: SessionFollowRequest): void {
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
}
}
function addressId(address: SessionAddress): SessionId {
return address.kind === 'session' ? address.sessionId : address.childSessionId
}
function validateAddress(
address: SessionAddress,
header: SessionHeader,
projections: SessionObservation['projections'],
): void {
if (address.kind === 'session') {
if (header.origin === 'subagent') {
throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
reason: 'use subagent delivery for this child session',
})
}
return
}
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
childSessionId: address.childSessionId,
})
}
const identity = projections?.values.subagent
if (identity === null) {
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'corrupt',
})
}
if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'unsupported',
})
}
if (identity.mode !== address.mode) {
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
}
}
function rejectNotFound(address: SessionAddress): never {
if (address.kind === 'session') {
throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
}
throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
})
}
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
throughSeq = events.at(-1)?.seq ?? -1,
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)
let count = 0
let cut = 0
for (let index = end - 1; index >= 0; index--) {
const event = events[index] as SessionEvent
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
let groupStart = event.seq
if (sources !== undefined) {
for (const source of sources) groupStart = Math.min(groupStart, source)
}
if (count >= maxMessages) {
cut = groupStart
break
}
}
return { events: events.slice(cut, end), hasMore: cut > 0 }
}
function entryFor(event: SessionEvent): SessionEventEntry {
return {
type: 'event',
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
}
}
function chunkEntryFor(row: ChunkRow): SessionChunkRun {
switch (row.type) {
case 'text-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'reasoning-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
case 'tool-call-chunks':
return {
type: 'chunks',
event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data },
}
}
}
/** Encode one bounded logical page without changing its pagination cut. */
function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
return packChunkRuns(events).map(record => isChunkRow(record)
? chunkEntryFor(record)
: entryFor(record))
}