feat(apiproxy): host session-log download surface

Streams one ZIP of the root session artifact plus each subagent descendant
verbatim (the persistence readRaw bytes) from GET /api/session.export as a
host-only download — no wire envelope, absent from IApiClient. The downloads
domain owns the query schema, the fetch handler answers the GET alongside the
SSE routes, and compression runs on the host with fflate's streaming Zip API.
This commit is contained in:
_Kerman
2026-08-10 17:47:24 +08:00
parent 80b7f929ea
commit ded90bffba
16 changed files with 456 additions and 5 deletions

View File

@@ -0,0 +1,176 @@
/**
* Host-side session-log download: streams one ZIP archive whose files are the
* sessions' stored artifact text verbatim. The root artifact sits under its
* original base name (`session.jsonl`); each subagent descendant under
* `subagents/<id>/<filename>`. No manifest is written — every file is
* byte-identical to the backend's durable artifact and self-describing
* through its own header line. Compression happens on the host with fflate's
* streaming Zip API, so the response is chunked as it is produced and the
* host never materializes the whole archive.
* @module
*/
import { Zip, ZipDeflate } from 'fflate'
import type { Context } from 'cordis'
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
/** The services a session-log export needs (absent → the export is unavailable). */
export interface SessionLogExportDeps {
readonly sessionQuery: SessionQueryService | undefined
readonly sessionPersistence: SessionPersistence | undefined
}
/**
* Resolve the persistence and session-query services a log export needs.
* @param ctx - the composed host context.
* @returns the export services (absent when the deployment does not mount them).
*/
export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
return {
sessionQuery: ctx.get('sessionQuery'),
sessionPersistence: ctx.get('sessionPersistence'),
}
}
/** One exported artifact: the stored text plus the zip path it lands at. */
export interface SessionLogZipEntry {
/** Zip entry path (root filename verbatim; descendants under `subagents/<id>/`). */
readonly path: string
/** The stored artifact text verbatim. */
readonly content: string
}
/**
* One safe zip path segment from an untrusted session id. Session ids are
* host-controlled, but the brand allows any non-empty string, so `../` and
* separator characters are neutralized before they can shape archive entries.
* @param id - the raw session id.
* @returns a filesystem-safe single path segment.
*/
function safeSessionIdSegment(id: string): string {
return id.replace(/[^A-Za-z0-9._-]/g, '_')
}
/**
* The export archive filename for one root session.
* @param sessionId - the root session id (sanitized to one safe path segment).
* @returns the attachment filename for the session's export archive.
*/
export function sessionLogZipFilename(sessionId: string): string {
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
}
/**
* Yield the export entries in zip order: the preloaded root artifact first,
* then every subagent descendant in lineage order, each read from the
* persistence backend right before it is yielded and dropped after the
* consumer moves on (the host holds at most one descendant's artifact text at
* a time beyond the root).
* @param deps - the export services.
* @param root - the already-read root artifact (read by the caller so the
* missing-session path can answer cleanly before streaming starts).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation for read work.
* @returns the export entries in zip order.
*/
export async function* sessionLogZipEntries(
deps: SessionLogExportDeps,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): AsyncGenerator<SessionLogZipEntry> {
yield { path: root.filename, content: root.content }
if (!includeDescendants) return
const sessionQuery = deps.sessionQuery
const sessionPersistence = deps.sessionPersistence
if (sessionQuery === undefined || sessionPersistence === undefined) {
// The caller validated services before the stream started; this arm is
// unreachable today and guards a future caller that skips the check.
throw new Error('session log export is unavailable: missing session-query or session-persistence service')
}
const seen = new Set<SessionId>([sessionId])
const collect = async function* (
nodes: readonly SessionLineageNode[],
): AsyncGenerator<SessionLogZipEntry> {
for (const node of nodes) {
signal?.throwIfAborted()
const id = node.session.header.id
if (seen.has(id)) continue
seen.add(id)
const raw = await sessionPersistence.readRaw(id)
if (raw === undefined) {
throw new Error(`subagent "${id}" has no stored log artifact`)
}
yield {
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
content: raw.content,
}
yield* collect(node.descendants)
}
}
const lineage = await sessionQuery.traceSession(sessionId)
yield* collect(lineage.descendants)
}
/** How many code points of artifact text one zip push carries (bounded encode memory). */
const PUSH_CHUNK_CODE_POINTS = 1 << 16
/**
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
* read and validated by the caller before this is called (missing root or
* missing services answer cleanly before any byte is produced); each entry is
* then encoded and deflated in bounded chunks as it is produced, so the
* archive bytes arrive incrementally. A descendant that fails to read errors
* the stream (fail-loud, never silent under-export).
* @param deps - the export services.
* @param root - the already-read root artifact (first zip entry).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation for read work.
* @returns the zip byte stream.
*/
export function streamSessionLogZip(
deps: SessionLogExportDeps,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream<Uint8Array>({
start(controller) {
// fflate invokes the callback synchronously per compressed chunk;
// enqueued bytes stay bounded by the compressed archive size (the body
// consumer drains them over the wire as the stream is pulled).
const zip = new Zip((error, data, final) => {
if (error) {
controller.error(error)
return
}
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
void (async () => {
try {
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
const deflate = new ZipDeflate(entry.path, { level: 6 })
zip.add(deflate)
const content = entry.content
for (let offset = 0; offset < content.length; offset += PUSH_CHUNK_CODE_POINTS) {
signal?.throwIfAborted()
const finalChunk = offset + PUSH_CHUNK_CODE_POINTS >= content.length
deflate.push(encoder.encode(content.slice(offset, offset + PUSH_CHUNK_CODE_POINTS)), finalChunk)
}
}
zip.end()
} catch (error) {
// A mid-stream failure (missing descendant, cancellation, read
// error) must fail the download rather than ship a truncated archive.
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
})
}