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

@@ -41,6 +41,12 @@ import type {
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
sessionLogExportDeps,
sessionLogZipFilename,
streamSessionLogZip,
} from './session-export.ts'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
@@ -3348,6 +3354,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
downloads: {
async sessionLog(request, signal) {
// Clean error path first: missing services answer 500 and a missing
// root artifact 404 before any zip byte is produced. The root content
// read here is reused as the first zip entry, so nothing is read twice.
const deps = sessionLogExportDeps(ctx)
if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined) {
return new Response(
'session log export is unavailable: missing session-query or session-persistence service',
{ status: 500 },
)
}
let root: SessionRawArtifact | undefined
try {
root = await deps.sessionPersistence.readRaw(request.sessionId)
} catch (error: unknown) {
return new Response(String(error), { status: 500 })
}
if (root === undefined) {
return new Response('session not found', { status: 404 })
}
return new Response(
streamSessionLogZip(deps, root, request.sessionId, request.includeDescendants === true, signal),
{
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
},
},
)
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Route by the echoed rpcId (the wire correlation): approvals first,
// then questions — the two registries share one id space of UUIDs.

View File

@@ -0,0 +1,22 @@
/**
* downloads domain zod schemas. The GET download surface has no wire
* envelope: the request arrives as query parameters (all strings), so its
* request schema parses the raw query-parameter object into the method's
* exact request shape. SessionId brand cast point: sessionIdSchema, and only
* there (hosted in sessions.schema like every other cast).
*/
import { z } from 'zod'
import type { DownloadsApi } from './downloads.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/** session.export query params → the sessionLog request. */
export const sessionLogQuerySchema = z
.object({
sessionId: sessionIdSchema,
includeDescendants: z.string().optional(),
})
.transform(query => ({
sessionId: query.sessionId,
...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
})) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>

View File

@@ -0,0 +1,25 @@
/**
* downloads domain contract: host-only download surfaces — the GET-download
* channel family, the mirror of the SSE-stream `events` domain. No wire
* envelope: the carrier's GET routes answer these directly, and the browser
* `IApiClient` never exposes them.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
export interface DownloadsApi {
/**
* Stream one session-log ZIP — the root artifact verbatim plus each subagent
* descendant's — as an attachment response. The carrier's GET route answers
* this directly; the browser never calls it.
* @param request - the root session id and whether to include descendants.
* @param signal - cancellation for the underlying reads.
* @returns the ZIP attachment response; missing services answer 500 and a
* missing root session 404 before any byte is produced.
*/
sessionLog(
request: { sessionId: SessionId; includeDescendants?: boolean },
signal: AbortSignal,
): Promise<Response>
}

View File

@@ -16,6 +16,7 @@ import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts'
import type { DownloadsApi } from './downloads.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
@@ -32,6 +33,8 @@ export interface ApiProxy {
settings: SettingsApi
credentials: CredentialsApi
llm: LlmApi
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
downloads: DownloadsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
}
@@ -39,9 +42,8 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
@@ -57,6 +59,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
export type { DownloadsApi } from './downloads.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -9,6 +9,7 @@
import { randomUUID } from 'node:crypto'
import type { z } from 'zod'
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
@@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
const url = new URL(req.url)
const path = url.pathname
// No-envelope GET channel surface (SSE streams + host-only download):
// physical routes that answer directly, without a wire envelope.
if (path === '/api/events.mux' && req.method === 'GET') {
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (path === '/api/events.host' && req.method === 'GET') {
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
}
if (path === '/api/session.export' && req.method === 'GET') {
// Query params are a different boundary from the POST envelope, but
// the request still casts its brands only through the domain schema.
const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return new Response('missing or invalid sessionId query parameter', { status: 400 })
}
return api.downloads.sessionLog(parsed.data, req.signal)
}
if (req.method !== 'POST' || !path.startsWith('/api/')) {
return new Response('not found', { status: 404 })

View File

@@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly credentials: ApiProxy['credentials']
readonly llm: ApiProxy['llm']
readonly events: ApiProxy['events']
readonly downloads: ApiProxy['downloads']
readonly respond: ApiProxy['respond']
constructor(ctx: Context, config: Config) {
@@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy {
this.credentials = api.credentials
this.llm = api.llm
this.events = api.events
this.downloads = api.downloads
// createApiProxy returns closures (no `this` capture), so the bind is
// behavior-neutral.
this.respond = api.respond.bind(api)

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)))
}
})()
},
})
}