mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
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
166 lines
5.4 KiB
TypeScript
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
|
|
}
|