Files
deepseek-harness/packages/session-query/session-log-export/src/index.ts
T

163 lines
5.3 KiB
TypeScript

/** Session-log download command and Host-owned streaming route. */
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
flushLiveSessionLog,
sessionLogExportDeps,
sessionLogZipFilename,
streamSessionLogZip,
type SessionLogCompressionLevel,
type SessionLogExportReady,
} from './archive.ts'
export {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
flushLiveSessionLog,
sessionLogExportDeps,
sessionLogZipEntries,
sessionLogZipFilename,
streamSessionLogZip,
} from './archive.ts'
export type {
SessionLogCompressionLevel,
SessionLogExportDeps,
SessionLogExportReady,
SessionLogZipEntry,
} from './archive.ts'
export const name = 'session-log-download'
export const inject = ['commands', 'connection']
/** Stable browser download path retained across the transport migration. */
export const SESSION_LOG_EXPORT_PATH = '/api/session.export'
/** Session-log archive policy. */
export interface Config {
/** DEFLATE level for each ZIP entry. @default 6 */
readonly compressionLevel?: SessionLogCompressionLevel
}
/** Validate Session-log archive configuration. */
export const Config: Schema<Config> = Schema.object({
compressionLevel: Schema.number().step(1).min(0).max(9)
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema<SessionLogCompressionLevel>,
})
interface SessionLogConnection {
readonly fetch: {
register(route: {
readonly path: string
readonly methods: readonly ('GET' | 'HEAD')[]
readonly fetch: (request: Request) => Promise<Response>
}): () => Promise<void>
}
}
const REQUESTED: CommandResult = {
kind: 'success',
text: 'Session log download requested.',
}
/**
* Register the Web-only `/export` command and authenticated ZIP download route.
* @param ctx - Host context carrying the human-command registry.
* @param config - resolved compression policy.
*/
export function apply(ctx: Context, config: Config = {}): void {
ctx.effect(() => ctx.commands.register({
name: 'export',
description: 'Download this Session log as a ZIP archive',
handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
? REQUESTED
: { kind: 'error', text: 'The Web /export command does not accept a path.' }),
}), 'session-log-download: command')
connectionOf(ctx).fetch.register({
path: SESSION_LOG_EXPORT_PATH,
methods: ['GET', 'HEAD'],
fetch: async (request) => {
const response = await sessionLogExportResponse(
ctx,
request,
config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
)
if (request.method === 'GET') return response
await response.body?.cancel()
return new Response(null, { status: response.status, headers: response.headers })
},
})
}
function connectionOf(ctx: Context): SessionLogConnection {
return Reflect.get(ctx, 'connection') as SessionLogConnection
}
async function sessionLogExportResponse(
ctx: Context,
request: Request,
compressionLevel: SessionLogCompressionLevel,
): Promise<Response> {
const url = new URL(request.url)
const query = Object.fromEntries(url.searchParams)
const sessionIdValue = query['sessionId']
const descendantsValue = query['includeDescendants']
if (sessionIdValue === undefined || sessionIdValue.length === 0
|| (descendantsValue !== undefined && descendantsValue !== 'true' && descendantsValue !== 'false')) {
return new Response('missing or invalid sessionId query parameter', { status: 400 })
}
const sessionId = SessionId(sessionIdValue)
const deps = sessionLogExportDeps(ctx)
if (deps.sessionQuery === undefined
|| deps.sessionPersistence === undefined
|| deps.attachments === undefined) {
return new Response(
'session log export is unavailable: missing session-query, session-persistence, or attachments service',
{ status: 500 },
)
}
if (!deps.sessionPersistence.supportsRawArtifacts) {
return new Response(
'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
{ status: 501 },
)
}
const ready: SessionLogExportReady = {
sessionQuery: deps.sessionQuery,
sessionPersistence: deps.sessionPersistence,
attachments: deps.attachments,
sessions: deps.sessions,
}
let root: SessionRawArtifact | undefined
try {
await flushLiveSessionLog(deps, sessionId, request.signal)
root = await deps.sessionPersistence.readRaw(sessionId, request.signal)
request.signal.throwIfAborted()
} catch {
request.signal.throwIfAborted()
return new Response('session log export failed to prepare the stored artifact', { status: 500 })
}
if (root === undefined) return new Response('session not found', { status: 404 })
const response = new Response(
streamSessionLogZip(
ready,
root,
sessionId,
descendantsValue === 'true',
compressionLevel,
request.signal,
),
{
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`,
},
},
)
return response
}