Files
deepseek-harness/packages/session-query/session-log-export/src/index.ts
T
Turtle bec6805d6a refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.

Refs #3245
2026-09-01 23:19:02 +08:00

166 lines
5.4 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 { brandString } from '@deepseek-ai/dsh-brand'
import type {} from '@deepseek-ai/dsh-attachment'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
flushLiveSessionLog,
readSessionLogText,
sessionLogExportDeps,
sessionLogZipFilename,
streamSessionLogZip,
type SessionLogCompressionLevel,
type SessionLogExportReady,
} from './archive.ts'
export {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
flushLiveSessionLog,
readSessionLogText,
serializeSessionLog,
SESSION_LOG_FILENAME,
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 = brandString<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 },
)
}
const ready: SessionLogExportReady = {
sessionQuery: deps.sessionQuery,
sessionPersistence: deps.sessionPersistence,
attachments: deps.attachments,
sessions: deps.sessions,
}
let rootContent: string | undefined
try {
await flushLiveSessionLog(deps, sessionId, request.signal)
rootContent = await readSessionLogText(deps.sessionPersistence, sessionId, request.signal)
request.signal.throwIfAborted()
} catch {
request.signal.throwIfAborted()
// Root preparation failure (flush, open, or read): answer 500 without
// echoing the error, which may carry absolute host paths into the
// browser error bar.
return new Response('session log export failed to read the stored log', { status: 500 })
}
if (rootContent === undefined) {
return new Response('session not found', { status: 404 })
}
const response = new Response(
streamSessionLogZip(
ready,
rootContent,
sessionId,
descendantsValue === 'true',
compressionLevel,
request.signal,
),
{
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`,
},
},
)
return response
}