index.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /** Session-log download command and Host-owned streaming route. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import Schema from '@deepseek-ai/schemastery'
  4. import { brandString } from '@deepseek-ai/dsh-brand'
  5. import type {} from '@deepseek-ai/dsh-attachment'
  6. import type { CommandResult } from '@deepseek-ai/dsh-commands'
  7. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  8. import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
  9. import {
  10. DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
  11. flushLiveSessionLog,
  12. sessionLogExportDeps,
  13. sessionLogZipFilename,
  14. streamSessionLogZip,
  15. type SessionLogCompressionLevel,
  16. type SessionLogExportReady,
  17. } from './archive.ts'
  18. export {
  19. DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
  20. flushLiveSessionLog,
  21. sessionLogExportDeps,
  22. sessionLogZipEntries,
  23. sessionLogZipFilename,
  24. streamSessionLogZip,
  25. } from './archive.ts'
  26. export type {
  27. SessionLogCompressionLevel,
  28. SessionLogExportDeps,
  29. SessionLogExportReady,
  30. SessionLogZipEntry,
  31. } from './archive.ts'
  32. export const name = 'session-log-download'
  33. export const inject = ['commands', 'connection']
  34. /** Stable browser download path retained across the transport migration. */
  35. export const SESSION_LOG_EXPORT_PATH = '/api/session.export'
  36. /** Session-log archive policy. */
  37. export interface Config {
  38. /** DEFLATE level for each ZIP entry. @default 6 */
  39. readonly compressionLevel?: SessionLogCompressionLevel
  40. }
  41. /** Validate Session-log archive configuration. */
  42. export const Config: Schema<Config> = Schema.object({
  43. compressionLevel: Schema.number().step(1).min(0).max(9)
  44. .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema<SessionLogCompressionLevel>,
  45. })
  46. interface SessionLogConnection {
  47. readonly fetch: {
  48. register(route: {
  49. readonly path: string
  50. readonly methods: readonly ('GET' | 'HEAD')[]
  51. readonly fetch: (request: Request) => Promise<Response>
  52. }): () => Promise<void>
  53. }
  54. }
  55. const REQUESTED: CommandResult = {
  56. kind: 'success',
  57. text: 'Session log download requested.',
  58. }
  59. /**
  60. * Register the Web-only `/export` command and authenticated ZIP download route.
  61. * @param ctx - Host context carrying the human-command registry.
  62. * @param config - resolved compression policy.
  63. */
  64. export function apply(ctx: Context, config: Config = {}): void {
  65. ctx.effect(() => ctx.commands.register({
  66. name: 'export',
  67. description: 'Download this Session log as a ZIP archive',
  68. handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
  69. ? REQUESTED
  70. : { kind: 'error', text: 'The Web /export command does not accept a path.' }),
  71. }), 'session-log-download: command')
  72. connectionOf(ctx).fetch.register({
  73. path: SESSION_LOG_EXPORT_PATH,
  74. methods: ['GET', 'HEAD'],
  75. fetch: async (request) => {
  76. const response = await sessionLogExportResponse(
  77. ctx,
  78. request,
  79. config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
  80. )
  81. if (request.method === 'GET') return response
  82. await response.body?.cancel()
  83. return new Response(null, { status: response.status, headers: response.headers })
  84. },
  85. })
  86. }
  87. function connectionOf(ctx: Context): SessionLogConnection {
  88. return Reflect.get(ctx, 'connection') as SessionLogConnection
  89. }
  90. async function sessionLogExportResponse(
  91. ctx: Context,
  92. request: Request,
  93. compressionLevel: SessionLogCompressionLevel,
  94. ): Promise<Response> {
  95. const url = new URL(request.url)
  96. const query = Object.fromEntries(url.searchParams)
  97. const sessionIdValue = query['sessionId']
  98. const descendantsValue = query['includeDescendants']
  99. if (sessionIdValue === undefined || sessionIdValue.length === 0
  100. || (descendantsValue !== undefined && descendantsValue !== 'true' && descendantsValue !== 'false')) {
  101. return new Response('missing or invalid sessionId query parameter', { status: 400 })
  102. }
  103. const sessionId = brandString<SessionId>(sessionIdValue)
  104. const deps = sessionLogExportDeps(ctx)
  105. if (deps.sessionQuery === undefined
  106. || deps.sessionPersistence === undefined
  107. || deps.attachments === undefined) {
  108. return new Response(
  109. 'session log export is unavailable: missing session-query, session-persistence, or attachments service',
  110. { status: 500 },
  111. )
  112. }
  113. if (!deps.sessionPersistence.supportsRawArtifacts) {
  114. return new Response(
  115. 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
  116. { status: 501 },
  117. )
  118. }
  119. const ready: SessionLogExportReady = {
  120. sessionQuery: deps.sessionQuery,
  121. sessionPersistence: deps.sessionPersistence,
  122. attachments: deps.attachments,
  123. sessions: deps.sessions,
  124. }
  125. let root: SessionRawArtifact | undefined
  126. try {
  127. await flushLiveSessionLog(deps, sessionId, request.signal)
  128. root = await deps.sessionPersistence.readRaw(sessionId, request.signal)
  129. request.signal.throwIfAborted()
  130. } catch {
  131. request.signal.throwIfAborted()
  132. return new Response('session log export failed to prepare the stored artifact', { status: 500 })
  133. }
  134. if (root === undefined) return new Response('session not found', { status: 404 })
  135. const response = new Response(
  136. streamSessionLogZip(
  137. ready,
  138. root,
  139. sessionId,
  140. descendantsValue === 'true',
  141. compressionLevel,
  142. request.signal,
  143. ),
  144. {
  145. headers: {
  146. 'content-type': 'application/zip',
  147. 'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`,
  148. },
  149. },
  150. )
  151. return response
  152. }