| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- /**
- * Host-side session-log download: streams one ZIP archive whose files are the
- * sessions' stored artifact text verbatim plus every referenced media object.
- * The root artifact sits under its original base name (`session.jsonl`); each
- * subagent descendant under `subagents/<id>/<filename>`; each image referenced
- * by any included log under `media/<attachmentId>.<ext>` (content-addressed,
- * so one archive never duplicates a shared image). No manifest is written —
- * every file is byte-identical to the backend's durable artifact or attachment
- * store and self-describing through its own header line or media type. Before
- * each live session's artifact read, the SessionStore flush barrier makes the
- * current in-memory log durable; cold sessions need no barrier. Request abort
- * and response-consumer cancellation share one producer signal and terminate
- * the active compressor.
- * Compression runs on the host with fflate's streaming Zip API, so the archive
- * bytes are produced incrementally and the host never holds the whole archive
- * in one buffer; production waits for consumer pull whenever the response queue
- * reaches its byte high-water mark, so a slow consumer bounds accumulation to
- * the fixed 64 KiB response queue plus one synchronous fflate push.
- * @module
- */
- import { Zip, ZipDeflate } from 'fflate'
- import type { Context } from '@deepseek-ai/cordis'
- import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
- import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
- import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
- import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
- /** Valid fflate DEFLATE levels accepted by session-log export. */
- export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
- /** Balanced default used when a direct createApiProxy caller omits deployment config. */
- export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
- /** The services a session-log export needs (the live-session store is optional). */
- export interface SessionLogExportDeps {
- readonly sessionQuery: SessionQueryService | undefined
- readonly sessionPersistence: SessionPersistence | undefined
- readonly attachments: AttachmentStore | undefined
- readonly sessions: SessionStore | undefined
- }
- /** The export services narrowed to the mounted ones streaming actually reads. */
- export interface SessionLogExportReady {
- readonly sessionQuery: SessionQueryService
- readonly sessionPersistence: SessionPersistence
- readonly attachments: AttachmentStore
- readonly sessions: SessionStore | undefined
- }
- /**
- * Resolve the persistence, session-query, and attachment services a log export needs.
- * @param ctx - the composed host context.
- * @returns the export services (absent when the deployment does not mount them).
- */
- export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
- return {
- sessionQuery: ctx.get('sessionQuery'),
- sessionPersistence: ctx.get('sessionPersistence'),
- attachments: ctx.get('attachments'),
- sessions: ctx.get('sessions'),
- }
- }
- /**
- * Flush one currently live session through the store's authoritative durability
- * barrier immediately before its raw artifact is read. A cold or absent id has
- * no in-memory work to flush.
- * @param deps - export services, including the optional live-session store.
- * @param id - the session whose artifact is about to be read.
- * @param signal - optional cancellation observed around the flush barrier.
- */
- export async function flushLiveSessionLog(
- deps: Pick<SessionLogExportDeps, 'sessions'>,
- id: SessionId,
- signal?: AbortSignal,
- ): Promise<void> {
- signal?.throwIfAborted()
- const sessions = deps.sessions
- if (sessions === undefined) return
- const session = sessions.get(id)
- if (session === undefined) return
- await sessions.flush(session)
- signal?.throwIfAborted()
- }
- /** One exported file: a stored artifact text or one referenced media object. */
- export type SessionLogZipEntry =
- | { readonly path: string; readonly content: string }
- | { readonly path: string; readonly data: Uint8Array }
- /** Zip extension for each accepted raster media type. */
- const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
- 'image/png': 'png',
- 'image/jpeg': 'jpg',
- 'image/webp': 'webp',
- 'image/gif': 'gif',
- }
- /**
- * The zip path for one media object: content-addressed by the opaque
- * attachment id so shared images land once and the id in the log maps back to
- * the archive entry without a manifest.
- * @param ref - the durable reference from a session log.
- * @returns the archive path.
- */
- function mediaEntryPath(ref: ImageAttachmentRef): string {
- return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
- }
- /**
- * Collect every image reference inside one content array, descending into
- * nested tool results the way the live attachment route does.
- * @param content - an event content array (or nested tool-result content).
- * @param refs - the dedupe map being filled (keyed by attachment id).
- */
- function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
- if (!Array.isArray(content)) return
- const pending: unknown[] = []
- for (const item of content) pending.push(item)
- while (pending.length > 0) {
- const value = pending.pop()
- if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
- const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
- if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
- const ref = block.attachment as ImageAttachmentRef
- refs.set(String(ref.attachmentId), ref)
- }
- if (Array.isArray(block.content)) {
- for (const item of block.content) pending.push(item)
- }
- }
- }
- /**
- * Collect every image reference one session event carries, across the same
- * carriers the live attachment route scans (direct content, message content,
- * inserted messages, and completed assistant chunk blocks).
- * @param event - one parsed JSONL event object.
- * @param refs - the dedupe map being filled (keyed by attachment id).
- */
- function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
- const data = (event as { data?: unknown }).data
- if (typeof data !== 'object' || data === null) return
- const carrier = data as {
- content?: unknown
- message?: { content?: unknown }
- inserted?: Array<{ content?: unknown }>
- chunk?: { type?: unknown; block?: unknown }
- }
- collectImageRefs(carrier.content, refs)
- if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
- if (carrier.inserted !== undefined) {
- for (const message of carrier.inserted) collectImageRefs(message.content, refs)
- }
- if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
- }
- /**
- * Collect the distinct media references one stored artifact text names.
- * Lines that fail to parse cannot reference media and are skipped (the
- * artifact text itself is exported verbatim regardless).
- * @param content - the stored artifact text.
- * @returns the dedupe map keyed by attachment id.
- */
- function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
- const refs = new Map<string, ImageAttachmentRef>()
- for (const line of content.split('\n')) {
- if (line === '') continue
- let event: unknown
- try {
- event = JSON.parse(line)
- } catch {
- continue
- }
- collectEventImageRefs(event, refs)
- }
- return refs
- }
- /**
- * One safe zip path segment from an untrusted session id. Session ids are
- * host-controlled, but the brand allows any non-empty string, so `../`, dot
- * segments, and separator characters are neutralized before they can shape
- * archive entries. Distinct ids may collapse onto one segment (id collision
- * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
- * @param id - the raw session id.
- * @returns a filesystem-safe single path segment.
- */
- function safeSessionIdSegment(id: string): string {
- return id.replace(/[^A-Za-z0-9_-]/g, '_')
- }
- /**
- * The export archive filename for one root session.
- * @param sessionId - the root session id (sanitized to one safe path segment).
- * @returns the attachment filename for the session's export archive.
- */
- export function sessionLogZipFilename(sessionId: string): string {
- return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
- }
- /**
- * Yield the export entries in zip order: the preloaded root artifact first,
- * then every subagent descendant in lineage order (each flushed when live,
- * read from the persistence backend right before it is yielded, and dropped
- * after the consumer moves on), then every distinct media object referenced by any of
- * the included logs (read and verified from the attachment store, one archive
- * entry per attachment id). The host holds at most one descendant's artifact
- * text and one media object at a time beyond the root.
- * @param deps - the mounted export services (the caller answered 500 before this runs).
- * @param root - the already-read root artifact (read by the caller so the
- * missing-session path can answer cleanly before streaming starts).
- * @param sessionId - the root session id.
- * @param includeDescendants - whether to include every subagent descendant.
- * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
- * @returns the export entries in zip order.
- */
- export async function* sessionLogZipEntries(
- deps: SessionLogExportReady,
- root: SessionRawArtifact,
- sessionId: SessionId,
- includeDescendants: boolean,
- signal?: AbortSignal,
- ): AsyncGenerator<SessionLogZipEntry> {
- const media = new Map<string, ImageAttachmentRef>()
- const rememberMedia = (content: string): void => {
- for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
- }
- rememberMedia(root.content)
- yield { path: root.filename, content: root.content }
- if (includeDescendants) {
- const seen = new Set<SessionId>([sessionId])
- const collect = async function* (
- nodes: readonly SessionLineageNode[],
- ): AsyncGenerator<SessionLogZipEntry> {
- for (const node of nodes) {
- signal?.throwIfAborted()
- const id = node.session.header.id
- if (seen.has(id)) continue
- seen.add(id)
- await flushLiveSessionLog(deps, id, signal)
- const raw = await deps.sessionPersistence.readRaw(id, signal)
- signal?.throwIfAborted()
- if (raw === undefined) {
- throw new Error(`subagent "${id}" has no stored log artifact`)
- }
- rememberMedia(raw.content)
- yield {
- path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
- content: raw.content,
- }
- yield* collect(node.descendants)
- }
- }
- const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
- signal?.throwIfAborted()
- yield* collect(lineage.descendants)
- }
- for (const ref of media.values()) {
- signal?.throwIfAborted()
- const stored = await deps.attachments.readImage(ref, signal)
- signal?.throwIfAborted()
- yield { path: mediaEntryPath(ref), data: stored.data }
- }
- }
- /** How many code units of artifact text one zip push carries (bounded encode memory). */
- const PUSH_CHUNK_CODE_UNITS = 1 << 16
- /** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
- const PUSH_CHUNK_BYTES = 1 << 16
- /** Byte capacity retained by the response stream before ZIP production waits for pull. */
- const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
- /** One producer waiter released only when ReadableStream pull restores capacity. */
- class ResponseCapacityGate {
- private releasePending: (() => void) | undefined
- /**
- * Wait until the response queue has positive byte capacity or cancellation wins.
- * @param controller - response controller whose desired size owns capacity.
- * @param signal - combined request/consumer cancellation.
- */
- async wait(
- controller: ReadableStreamDefaultController<Uint8Array>,
- signal: AbortSignal,
- ): Promise<void> {
- signal.throwIfAborted()
- if (controller.desiredSize === null || controller.desiredSize > 0) return
- await new Promise<void>((resolve) => {
- const release = (): void => {
- this.releasePending = undefined
- signal.removeEventListener('abort', release)
- resolve()
- }
- this.releasePending = release
- signal.addEventListener('abort', release, { once: true })
- })
- signal.throwIfAborted()
- }
- /** Release the current producer waiter after a consumer pull. */
- pulled(): void {
- this.releasePending?.()
- }
- }
- /**
- * Push one media object's bytes into a deflate stream in bounded chunks,
- * waiting for consumer capacity between chunks like the artifact path does.
- * @param deflate - the zip entry's deflate stream.
- * @param data - the stored image bytes.
- * @param controller - response queue controller.
- * @param capacity - pull-driven response-capacity gate.
- * @param signal - cancellation; throws when aborted.
- */
- async function pushBinaryChunks(
- deflate: ZipDeflate,
- data: Uint8Array,
- controller: ReadableStreamDefaultController<Uint8Array>,
- capacity: ResponseCapacityGate,
- signal: AbortSignal,
- ): Promise<void> {
- let offset = 0
- do {
- signal.throwIfAborted()
- const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
- const finalChunk = end >= data.byteLength
- deflate.push(data.subarray(offset, end), finalChunk)
- offset = end
- await capacity.wait(controller, signal)
- } while (offset < data.byteLength)
- }
- /**
- * Push one artifact's text into a deflate stream in bounded chunks, never
- * splitting a surrogate pair across a chunk boundary (a lone high surrogate
- * re-encodes as U+FFFD and would silently corrupt the exported artifact).
- * @param deflate - the zip entry's deflate stream.
- * @param content - the artifact text verbatim.
- * @param controller - response queue controller.
- * @param capacity - pull-driven response-capacity gate.
- * @param signal - cancellation; throws when aborted.
- */
- async function pushArtifactChunks(
- deflate: ZipDeflate,
- content: string,
- controller: ReadableStreamDefaultController<Uint8Array>,
- capacity: ResponseCapacityGate,
- signal: AbortSignal,
- ): Promise<void> {
- const encoder = new TextEncoder()
- let offset = 0
- let finalChunk: boolean
- do {
- signal.throwIfAborted()
- let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
- if (end < content.length && end - offset > 1) {
- // Back off one code unit when the boundary lands inside a surrogate
- // pair: the pair then starts the next chunk whole.
- const last = content.charCodeAt(end - 1)
- if (last >= 0xd800 && last <= 0xdbff) end -= 1
- }
- finalChunk = end >= content.length
- deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
- offset = end
- await capacity.wait(controller, signal)
- } while (!finalChunk)
- }
- /**
- * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
- * read and validated by the caller before this is called (missing root or
- * missing services answer cleanly before any byte is produced); each entry is
- * then encoded and deflated in bounded chunks as it is produced, so the
- * archive bytes arrive incrementally. A descendant that fails to read errors
- * the stream (fail-loud, never silent under-export).
- * @param deps - the mounted export services (the caller answered 500 before this runs).
- * @param root - the already-read root artifact (first zip entry).
- * @param sessionId - the root session id.
- * @param includeDescendants - whether to include every subagent descendant.
- * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
- * @param signal - request cancellation combined with response-consumer cancellation.
- * @returns the zip byte stream.
- */
- export function streamSessionLogZip(
- deps: SessionLogExportReady,
- root: SessionRawArtifact,
- sessionId: SessionId,
- includeDescendants: boolean,
- compressionLevel: SessionLogCompressionLevel,
- signal: AbortSignal,
- ): ReadableStream<Uint8Array> {
- const consumerAbort = new AbortController()
- const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
- let zip: Zip | undefined
- let zipTerminated = false
- const capacity = new ResponseCapacityGate()
- const terminateZip = (): void => {
- if (zip === undefined || zipTerminated) return
- zipTerminated = true
- zip.terminate()
- }
- return new ReadableStream<Uint8Array>({
- start(controller) {
- // fflate invokes the callback synchronously per compressed chunk, so a
- // single push can enqueue ahead of a slow consumer; the capacity gate
- // waits for pull between pushes once the byte queue is full, bounding
- // accumulation to the queue high-water mark plus one synchronous push.
- const archive = new Zip((error, data, final) => {
- /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
- if (error) {
- controller.error(error)
- return
- }
- /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
- if (data.byteLength > 0) controller.enqueue(data)
- if (final) controller.close()
- })
- zip = archive
- void (async () => {
- try {
- for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
- const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
- archive.add(deflate)
- if ('content' in entry) {
- await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
- } else {
- await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
- }
- }
- archive.end()
- } catch (error) {
- // A mid-stream failure (missing descendant, cancellation, read
- // error) must fail the download rather than ship a truncated archive.
- /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
- terminateZip()
- controller.error(error instanceof Error ? error : new Error(String(error)))
- }
- })()
- },
- pull() {
- capacity.pulled()
- },
- cancel(reason) {
- consumerAbort.abort(
- reason instanceof Error ? reason : new Error('session log export stream cancelled'),
- )
- terminateZip()
- },
- }, {
- highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
- size: chunk => chunk.byteLength,
- })
- }
|