session-export.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. /**
  2. * Host-side session-log download: streams one ZIP archive whose files are the
  3. * sessions' stored artifact text verbatim plus every referenced media object.
  4. * The root artifact sits under its original base name (`session.jsonl`); each
  5. * subagent descendant under `subagents/<id>/<filename>`; each image referenced
  6. * by any included log under `media/<attachmentId>.<ext>` (content-addressed,
  7. * so one archive never duplicates a shared image). No manifest is written —
  8. * every file is byte-identical to the backend's durable artifact or attachment
  9. * store and self-describing through its own header line or media type. Before
  10. * each live session's artifact read, the SessionStore flush barrier makes the
  11. * current in-memory log durable; cold sessions need no barrier. Request abort
  12. * and response-consumer cancellation share one producer signal and terminate
  13. * the active compressor.
  14. * Compression runs on the host with fflate's streaming Zip API, so the archive
  15. * bytes are produced incrementally and the host never holds the whole archive
  16. * in one buffer; production waits for consumer pull whenever the response queue
  17. * reaches its byte high-water mark, so a slow consumer bounds accumulation to
  18. * the fixed 64 KiB response queue plus one synchronous fflate push.
  19. * @module
  20. */
  21. import { Zip, ZipDeflate } from 'fflate'
  22. import type { Context } from '@deepseek-ai/cordis'
  23. import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  24. import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
  25. import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
  26. import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
  27. /** Valid fflate DEFLATE levels accepted by session-log export. */
  28. export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
  29. /** Balanced default used when a direct createApiProxy caller omits deployment config. */
  30. export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
  31. /** The services a session-log export needs (the live-session store is optional). */
  32. export interface SessionLogExportDeps {
  33. readonly sessionQuery: SessionQueryService | undefined
  34. readonly sessionPersistence: SessionPersistence | undefined
  35. readonly attachments: AttachmentStore | undefined
  36. readonly sessions: SessionStore | undefined
  37. }
  38. /** The export services narrowed to the mounted ones streaming actually reads. */
  39. export interface SessionLogExportReady {
  40. readonly sessionQuery: SessionQueryService
  41. readonly sessionPersistence: SessionPersistence
  42. readonly attachments: AttachmentStore
  43. readonly sessions: SessionStore | undefined
  44. }
  45. /**
  46. * Resolve the persistence, session-query, and attachment services a log export needs.
  47. * @param ctx - the composed host context.
  48. * @returns the export services (absent when the deployment does not mount them).
  49. */
  50. export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
  51. return {
  52. sessionQuery: ctx.get('sessionQuery'),
  53. sessionPersistence: ctx.get('sessionPersistence'),
  54. attachments: ctx.get('attachments'),
  55. sessions: ctx.get('sessions'),
  56. }
  57. }
  58. /**
  59. * Flush one currently live session through the store's authoritative durability
  60. * barrier immediately before its raw artifact is read. A cold or absent id has
  61. * no in-memory work to flush.
  62. * @param deps - export services, including the optional live-session store.
  63. * @param id - the session whose artifact is about to be read.
  64. * @param signal - optional cancellation observed around the flush barrier.
  65. */
  66. export async function flushLiveSessionLog(
  67. deps: Pick<SessionLogExportDeps, 'sessions'>,
  68. id: SessionId,
  69. signal?: AbortSignal,
  70. ): Promise<void> {
  71. signal?.throwIfAborted()
  72. const sessions = deps.sessions
  73. if (sessions === undefined) return
  74. const session = sessions.get(id)
  75. if (session === undefined) return
  76. await sessions.flush(session)
  77. signal?.throwIfAborted()
  78. }
  79. /** One exported file: a stored artifact text or one referenced media object. */
  80. export type SessionLogZipEntry =
  81. | { readonly path: string; readonly content: string }
  82. | { readonly path: string; readonly data: Uint8Array }
  83. /** Zip extension for each accepted raster media type. */
  84. const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
  85. 'image/png': 'png',
  86. 'image/jpeg': 'jpg',
  87. 'image/webp': 'webp',
  88. 'image/gif': 'gif',
  89. }
  90. /**
  91. * The zip path for one media object: content-addressed by the opaque
  92. * attachment id so shared images land once and the id in the log maps back to
  93. * the archive entry without a manifest.
  94. * @param ref - the durable reference from a session log.
  95. * @returns the archive path.
  96. */
  97. function mediaEntryPath(ref: ImageAttachmentRef): string {
  98. return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
  99. }
  100. /**
  101. * Collect every image reference inside one content array, descending into
  102. * nested tool results the way the live attachment route does.
  103. * @param content - an event content array (or nested tool-result content).
  104. * @param refs - the dedupe map being filled (keyed by attachment id).
  105. */
  106. function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
  107. if (!Array.isArray(content)) return
  108. const pending: unknown[] = []
  109. for (const item of content) pending.push(item)
  110. while (pending.length > 0) {
  111. const value = pending.pop()
  112. if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
  113. const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
  114. if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
  115. const ref = block.attachment as ImageAttachmentRef
  116. refs.set(String(ref.attachmentId), ref)
  117. }
  118. if (Array.isArray(block.content)) {
  119. for (const item of block.content) pending.push(item)
  120. }
  121. }
  122. }
  123. /**
  124. * Collect every image reference one session event carries, across the same
  125. * carriers the live attachment route scans (direct content, message content,
  126. * inserted messages, and completed assistant chunk blocks).
  127. * @param event - one parsed JSONL event object.
  128. * @param refs - the dedupe map being filled (keyed by attachment id).
  129. */
  130. function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
  131. const data = (event as { data?: unknown }).data
  132. if (typeof data !== 'object' || data === null) return
  133. const carrier = data as {
  134. content?: unknown
  135. message?: { content?: unknown }
  136. inserted?: Array<{ content?: unknown }>
  137. chunk?: { type?: unknown; block?: unknown }
  138. }
  139. collectImageRefs(carrier.content, refs)
  140. if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
  141. if (carrier.inserted !== undefined) {
  142. for (const message of carrier.inserted) collectImageRefs(message.content, refs)
  143. }
  144. if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
  145. }
  146. /**
  147. * Collect the distinct media references one stored artifact text names.
  148. * Lines that fail to parse cannot reference media and are skipped (the
  149. * artifact text itself is exported verbatim regardless).
  150. * @param content - the stored artifact text.
  151. * @returns the dedupe map keyed by attachment id.
  152. */
  153. function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
  154. const refs = new Map<string, ImageAttachmentRef>()
  155. for (const line of content.split('\n')) {
  156. if (line === '') continue
  157. let event: unknown
  158. try {
  159. event = JSON.parse(line)
  160. } catch {
  161. continue
  162. }
  163. collectEventImageRefs(event, refs)
  164. }
  165. return refs
  166. }
  167. /**
  168. * One safe zip path segment from an untrusted session id. Session ids are
  169. * host-controlled, but the brand allows any non-empty string, so `../`, dot
  170. * segments, and separator characters are neutralized before they can shape
  171. * archive entries. Distinct ids may collapse onto one segment (id collision
  172. * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
  173. * @param id - the raw session id.
  174. * @returns a filesystem-safe single path segment.
  175. */
  176. function safeSessionIdSegment(id: string): string {
  177. return id.replace(/[^A-Za-z0-9_-]/g, '_')
  178. }
  179. /**
  180. * The export archive filename for one root session.
  181. * @param sessionId - the root session id (sanitized to one safe path segment).
  182. * @returns the attachment filename for the session's export archive.
  183. */
  184. export function sessionLogZipFilename(sessionId: string): string {
  185. return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
  186. }
  187. /**
  188. * Yield the export entries in zip order: the preloaded root artifact first,
  189. * then every subagent descendant in lineage order (each flushed when live,
  190. * read from the persistence backend right before it is yielded, and dropped
  191. * after the consumer moves on), then every distinct media object referenced by any of
  192. * the included logs (read and verified from the attachment store, one archive
  193. * entry per attachment id). The host holds at most one descendant's artifact
  194. * text and one media object at a time beyond the root.
  195. * @param deps - the mounted export services (the caller answered 500 before this runs).
  196. * @param root - the already-read root artifact (read by the caller so the
  197. * missing-session path can answer cleanly before streaming starts).
  198. * @param sessionId - the root session id.
  199. * @param includeDescendants - whether to include every subagent descendant.
  200. * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
  201. * @returns the export entries in zip order.
  202. */
  203. export async function* sessionLogZipEntries(
  204. deps: SessionLogExportReady,
  205. root: SessionRawArtifact,
  206. sessionId: SessionId,
  207. includeDescendants: boolean,
  208. signal?: AbortSignal,
  209. ): AsyncGenerator<SessionLogZipEntry> {
  210. const media = new Map<string, ImageAttachmentRef>()
  211. const rememberMedia = (content: string): void => {
  212. for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
  213. }
  214. rememberMedia(root.content)
  215. yield { path: root.filename, content: root.content }
  216. if (includeDescendants) {
  217. const seen = new Set<SessionId>([sessionId])
  218. const collect = async function* (
  219. nodes: readonly SessionLineageNode[],
  220. ): AsyncGenerator<SessionLogZipEntry> {
  221. for (const node of nodes) {
  222. signal?.throwIfAborted()
  223. const id = node.session.header.id
  224. if (seen.has(id)) continue
  225. seen.add(id)
  226. await flushLiveSessionLog(deps, id, signal)
  227. const raw = await deps.sessionPersistence.readRaw(id, signal)
  228. signal?.throwIfAborted()
  229. if (raw === undefined) {
  230. throw new Error(`subagent "${id}" has no stored log artifact`)
  231. }
  232. rememberMedia(raw.content)
  233. yield {
  234. path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
  235. content: raw.content,
  236. }
  237. yield* collect(node.descendants)
  238. }
  239. }
  240. const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
  241. signal?.throwIfAborted()
  242. yield* collect(lineage.descendants)
  243. }
  244. for (const ref of media.values()) {
  245. signal?.throwIfAborted()
  246. const stored = await deps.attachments.readImage(ref, signal)
  247. signal?.throwIfAborted()
  248. yield { path: mediaEntryPath(ref), data: stored.data }
  249. }
  250. }
  251. /** How many code units of artifact text one zip push carries (bounded encode memory). */
  252. const PUSH_CHUNK_CODE_UNITS = 1 << 16
  253. /** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
  254. const PUSH_CHUNK_BYTES = 1 << 16
  255. /** Byte capacity retained by the response stream before ZIP production waits for pull. */
  256. const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
  257. /** One producer waiter released only when ReadableStream pull restores capacity. */
  258. class ResponseCapacityGate {
  259. private releasePending: (() => void) | undefined
  260. /**
  261. * Wait until the response queue has positive byte capacity or cancellation wins.
  262. * @param controller - response controller whose desired size owns capacity.
  263. * @param signal - combined request/consumer cancellation.
  264. */
  265. async wait(
  266. controller: ReadableStreamDefaultController<Uint8Array>,
  267. signal: AbortSignal,
  268. ): Promise<void> {
  269. signal.throwIfAborted()
  270. if (controller.desiredSize === null || controller.desiredSize > 0) return
  271. await new Promise<void>((resolve) => {
  272. const release = (): void => {
  273. this.releasePending = undefined
  274. signal.removeEventListener('abort', release)
  275. resolve()
  276. }
  277. this.releasePending = release
  278. signal.addEventListener('abort', release, { once: true })
  279. })
  280. signal.throwIfAborted()
  281. }
  282. /** Release the current producer waiter after a consumer pull. */
  283. pulled(): void {
  284. this.releasePending?.()
  285. }
  286. }
  287. /**
  288. * Push one media object's bytes into a deflate stream in bounded chunks,
  289. * waiting for consumer capacity between chunks like the artifact path does.
  290. * @param deflate - the zip entry's deflate stream.
  291. * @param data - the stored image bytes.
  292. * @param controller - response queue controller.
  293. * @param capacity - pull-driven response-capacity gate.
  294. * @param signal - cancellation; throws when aborted.
  295. */
  296. async function pushBinaryChunks(
  297. deflate: ZipDeflate,
  298. data: Uint8Array,
  299. controller: ReadableStreamDefaultController<Uint8Array>,
  300. capacity: ResponseCapacityGate,
  301. signal: AbortSignal,
  302. ): Promise<void> {
  303. let offset = 0
  304. do {
  305. signal.throwIfAborted()
  306. const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
  307. const finalChunk = end >= data.byteLength
  308. deflate.push(data.subarray(offset, end), finalChunk)
  309. offset = end
  310. await capacity.wait(controller, signal)
  311. } while (offset < data.byteLength)
  312. }
  313. /**
  314. * Push one artifact's text into a deflate stream in bounded chunks, never
  315. * splitting a surrogate pair across a chunk boundary (a lone high surrogate
  316. * re-encodes as U+FFFD and would silently corrupt the exported artifact).
  317. * @param deflate - the zip entry's deflate stream.
  318. * @param content - the artifact text verbatim.
  319. * @param controller - response queue controller.
  320. * @param capacity - pull-driven response-capacity gate.
  321. * @param signal - cancellation; throws when aborted.
  322. */
  323. async function pushArtifactChunks(
  324. deflate: ZipDeflate,
  325. content: string,
  326. controller: ReadableStreamDefaultController<Uint8Array>,
  327. capacity: ResponseCapacityGate,
  328. signal: AbortSignal,
  329. ): Promise<void> {
  330. const encoder = new TextEncoder()
  331. let offset = 0
  332. let finalChunk: boolean
  333. do {
  334. signal.throwIfAborted()
  335. let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
  336. if (end < content.length && end - offset > 1) {
  337. // Back off one code unit when the boundary lands inside a surrogate
  338. // pair: the pair then starts the next chunk whole.
  339. const last = content.charCodeAt(end - 1)
  340. if (last >= 0xd800 && last <= 0xdbff) end -= 1
  341. }
  342. finalChunk = end >= content.length
  343. deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
  344. offset = end
  345. await capacity.wait(controller, signal)
  346. } while (!finalChunk)
  347. }
  348. /**
  349. * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
  350. * read and validated by the caller before this is called (missing root or
  351. * missing services answer cleanly before any byte is produced); each entry is
  352. * then encoded and deflated in bounded chunks as it is produced, so the
  353. * archive bytes arrive incrementally. A descendant that fails to read errors
  354. * the stream (fail-loud, never silent under-export).
  355. * @param deps - the mounted export services (the caller answered 500 before this runs).
  356. * @param root - the already-read root artifact (first zip entry).
  357. * @param sessionId - the root session id.
  358. * @param includeDescendants - whether to include every subagent descendant.
  359. * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
  360. * @param signal - request cancellation combined with response-consumer cancellation.
  361. * @returns the zip byte stream.
  362. */
  363. export function streamSessionLogZip(
  364. deps: SessionLogExportReady,
  365. root: SessionRawArtifact,
  366. sessionId: SessionId,
  367. includeDescendants: boolean,
  368. compressionLevel: SessionLogCompressionLevel,
  369. signal: AbortSignal,
  370. ): ReadableStream<Uint8Array> {
  371. const consumerAbort = new AbortController()
  372. const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
  373. let zip: Zip | undefined
  374. let zipTerminated = false
  375. const capacity = new ResponseCapacityGate()
  376. const terminateZip = (): void => {
  377. if (zip === undefined || zipTerminated) return
  378. zipTerminated = true
  379. zip.terminate()
  380. }
  381. return new ReadableStream<Uint8Array>({
  382. start(controller) {
  383. // fflate invokes the callback synchronously per compressed chunk, so a
  384. // single push can enqueue ahead of a slow consumer; the capacity gate
  385. // waits for pull between pushes once the byte queue is full, bounding
  386. // accumulation to the queue high-water mark plus one synchronous push.
  387. const archive = new Zip((error, data, final) => {
  388. /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
  389. if (error) {
  390. controller.error(error)
  391. return
  392. }
  393. /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
  394. if (data.byteLength > 0) controller.enqueue(data)
  395. if (final) controller.close()
  396. })
  397. zip = archive
  398. void (async () => {
  399. try {
  400. for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
  401. const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
  402. archive.add(deflate)
  403. if ('content' in entry) {
  404. await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
  405. } else {
  406. await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
  407. }
  408. }
  409. archive.end()
  410. } catch (error) {
  411. // A mid-stream failure (missing descendant, cancellation, read
  412. // error) must fail the download rather than ship a truncated archive.
  413. /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
  414. terminateZip()
  415. controller.error(error instanceof Error ? error : new Error(String(error)))
  416. }
  417. })()
  418. },
  419. pull() {
  420. capacity.pulled()
  421. },
  422. cancel(reason) {
  423. consumerAbort.abort(
  424. reason instanceof Error ? reason : new Error('session log export stream cancelled'),
  425. )
  426. terminateZip()
  427. },
  428. }, {
  429. highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
  430. size: chunk => chunk.byteLength,
  431. })
  432. }