feat(session-export): own the download route

This commit is contained in:
imccyu
2026-08-27 22:26:28 +08:00
parent e5e4b02742
commit 17c03bbbcc
17 changed files with 832 additions and 44 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
README.md: 455d7a14d0dd8e20347bdd7f8c76f12b88425d26
README.zh.md: 0c2a03b3460c499192784bddcac06a6400941d44
README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d
README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34
@@ -1,5 +1,5 @@
---
description: "Web Session-log export for users of the Web bundle: the Session Header download button and /export command, and what to expect from the download dialog."
description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-session-log-export` gives the Web interface a way to download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. A small dialog reports preparation, download start, or failure, shared by the button and the command. The ZIP is built and streamed by `dsh-host-apiproxy`; this package adds only the browser-side button and command. The download is a browser download: the browser chooses the destination. Setup and usage come first; the implementation internals live in a collapsible developer section below.
`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow.
## Table of Contents
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Use this package when the Web bundle should let users export a session log. It is mounted only by the Web bundle, beside the host API proxy, the command registry, and the conversation UI. The common path is: mount the plugin, then click `Session log` in the Session Header or type `/export` the browser downloads `dsh-session-<id>.zip`.
Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-<id>.zip`.
### When to choose it
@@ -38,7 +38,13 @@ Choose it for a Web deployment that needs user-facing session export with a visi
name: '@deepseek-ai/dsh-session-log-export'
```
The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`.
The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`.
### Configuration
| Field | Default | Meaning |
|---|---|---|
| `compressionLevel` | `6` | DEFLATE level from 0 through 9 for each ZIP entry. |
### Command contract
@@ -67,13 +73,13 @@ This section explains how the package wires the export control and points at the
### Design split
The package has two halves. The host half ([`src/index.ts`](src/index.ts)) registers the `/export` command on `ctx.commands`; the browser half ([`src/client/index.ts`](src/client/index.ts)) provides a `SessionLogDownloadController`, contributes the Header button and shared modal to the `conversation.session.header.utilities` slot, and observes `command/executed` so a successful `/export` in the submitting browser starts the same download. Other tabs still render the durable command row without repeating the browser side effect.
The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download.
### Download flow
Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy/README.md): it flushes a live root session before `readRaw` and streams the ZIP; ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics belong there.
The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics.
</details>
@@ -84,7 +90,7 @@ The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy
Read these pages when the package-level contract is not enough. They move from the Web control to the host endpoint and the surrounding command and session surfaces.
- [dsh-host-apiproxy](../../host/apiproxy/README.md) — the host-streamed ZIP download endpoint this package drives.
- [dsh-client-connection](../../client/connection/README.md) — the authenticated Fetch-route carrier used by the Host endpoint.
- [Commands subsystem reference](../../../docs/subsystems/commands.md) — the human-command registry the `/export` command registers on.
- [dsh-client-ui-commands](../../client/ui-commands/README.md) — the browser command surface that renders and acknowledges `/export`.
- [Session Query package map](../README.md) — the retrieval family this package belongs to.
@@ -1,5 +1,5 @@
---
description: "面向 Web bundle 用户的会话日志导出:Session Header 下载按钮与 /export 命令,以及下载弹窗的预期行为。"
description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。一个小弹窗报告准备中、开始下载或失败,按钮与命令共用该弹窗。ZIP 由 `dsh-host-apiproxy` 生成并流式传输;本包只提供浏览器侧的按钮与命令。下载是浏览器下载:目标位置由浏览器选择。设置与用法在前;实现内部细节放在下方可折叠的开发者章节中
`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
当 Web bundle 需要让用户导出会话日志时使用本包。它只由 Web bundle 挂载,与 Host API 代理、命令注册表和对话 UI 并列。常用路径是:挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`——浏览器下载 `dsh-session-<id>.zip`
当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`浏览器下载 `dsh-session-<id>.zip`
### 何时选择
@@ -38,7 +38,13 @@ kind: "package-reference"
name: '@deepseek-ai/dsh-session-log-export'
```
Web bundle 将本包与 `dsh-host-apiproxy``dsh-commands``dsh-client-ui-commands``dsh-client-ui-conversation` 一起挂载。
Web bundle 将本包与 Connection`dsh-commands``dsh-client-ui-commands``dsh-client-ui-conversation` 一起挂载。
### 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `compressionLevel` | `6` | 每个 ZIP 条目的 DEFLATE 级别,范围为 0 到 9。 |
### 命令约定
@@ -67,13 +73,13 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co
### 设计拆分
本包有两个半包。Host 半包([`src/index.ts`](src/index.ts) `ctx.commands` 上注册 `/export` 命令;浏览器半包([`src/client/index.ts`](src/client/index.ts))提供 `SessionLogDownloadController`,把 Header 按钮与共享弹窗贡献到 `conversation.session.header.utilities` slot,并观察 `command/executed`使提交命令的浏览器`/export` 成功后启动同一下载。其他标签页仍渲染持久命令行,但不会重复浏览器副作用
本包有两个半包。Host 半包([`src/index.ts`](src/index.ts)注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`因此只有提交命令的浏览器会启动下载
### 下载流程
两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥有:它在 `readRaw` 前 flush 活动的根会话并流式传输 ZIPZIP 生成、原始 JSONL/zstd 读取、子会话、附件、背压与 HTTP 错误语义都属于那里
Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义
</details>
@@ -84,7 +90,7 @@ Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥
当包级约定不够用时阅读以下页面。它们从 Web 控制逐步进入 Host 端点与周围的命令和会话表面。
- [dsh-host-apiproxy](../../host/apiproxy/README.zh.md)——本包驱动的 Host 流式 ZIP 下载端点
- [dsh-client-connection](../../client/connection/README.zh.md)——Host 端点使用的认证 Fetch 路由载体
- [命令子系统参考](../../../docs/subsystems/commands.zh.md)——`/export` 命令注册的用户命令注册表。
- [dsh-client-ui-commands](../../client/ui-commands/README.zh.md)——渲染并确认 `/export` 的浏览器命令表面。
- [会话查询包映射](../README.zh.md)——本包所属的检索能力家族。
@@ -21,20 +21,31 @@
"files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"],
"scripts": { "bundle": "tsdown", "watch": "tsdown --watch" },
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"fflate": "^0.8.2"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
@@ -46,6 +57,9 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
@@ -0,0 +1,457 @@
/**
* Host-side session-log download: streams one ZIP archive whose files are the
* sessions' stored artifact text verbatim plus every referenced media object.
* The root artifact sits under its original base name (`session.jsonl`); each
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
* so one archive never duplicates a shared image). No manifest is written —
* every file is byte-identical to the backend's durable artifact or attachment
* store and self-describing through its own header line or media type. Before
* each live session's artifact read, the SessionStore flush barrier makes the
* current in-memory log durable; cold sessions need no barrier. Request abort
* and response-consumer cancellation share one producer signal and terminate
* the active compressor.
* Compression runs on the host with fflate's streaming Zip API, so the archive
* bytes are produced incrementally and the host never holds the whole archive
* in one buffer; production waits for consumer pull whenever the response queue
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
* the fixed 64 KiB response queue plus one synchronous fflate push.
* @module
*/
import { Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
/** Valid fflate DEFLATE levels accepted by session-log export. */
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/** Balanced default used when Session export configuration omits a compression level. */
export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
/** The services a session-log export needs (the live-session store is optional). */
export interface SessionLogExportDeps {
readonly sessionQuery: SessionQueryEngine | undefined
readonly sessionPersistence: SessionPersistence | undefined
readonly attachments: AttachmentStore | undefined
readonly sessions: SessionStore | undefined
}
/** The export services narrowed to the mounted ones streaming actually reads. */
export interface SessionLogExportReady {
readonly sessionQuery: SessionQueryEngine
readonly sessionPersistence: SessionPersistence
readonly attachments: AttachmentStore
readonly sessions: SessionStore | undefined
}
/**
* Resolve the persistence, session-query, and attachment 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'),
attachments: ctx.get('attachments'),
sessions: ctx.get('sessions'),
}
}
/**
* Flush one currently live session through the store's authoritative durability
* barrier immediately before its raw artifact is read. A cold or absent id has
* no in-memory work to flush.
* @param deps - export services, including the optional live-session store.
* @param id - the session whose artifact is about to be read.
* @param signal - optional cancellation observed around the flush barrier.
*/
export async function flushLiveSessionLog(
deps: Pick<SessionLogExportDeps, 'sessions'>,
id: SessionId,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
const sessions = deps.sessions
if (sessions === undefined) return
const session = sessions.get(id)
if (session === undefined) return
await sessions.flush(session)
signal?.throwIfAborted()
}
/** One exported file: a stored artifact text or one referenced media object. */
export type SessionLogZipEntry =
| { readonly path: string; readonly content: string }
| { readonly path: string; readonly data: Uint8Array }
/** Zip extension for each accepted raster media type. */
const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}
/**
* The zip path for one media object: content-addressed by the opaque
* attachment id so shared images land once and the id in the log maps back to
* the archive entry without a manifest.
* @param ref - the durable reference from a session log.
* @returns the archive path.
*/
function mediaEntryPath(ref: ImageAttachmentRef): string {
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
}
/**
* Collect every image reference inside one content array, descending into
* nested tool results the way the live attachment route does.
* @param content - an event content array (or nested tool-result content).
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
if (!Array.isArray(content)) return
const pending: unknown[] = []
for (const item of content) pending.push(item)
while (pending.length > 0) {
const value = pending.pop()
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
refs.set(String(ref.attachmentId), ref)
}
if (Array.isArray(block.content)) {
for (const item of block.content) pending.push(item)
}
}
}
/**
* Collect every image reference one session event carries, across the same
* carriers the live attachment route scans (direct content, message content,
* inserted messages, and completed assistant chunk blocks).
* @param event - one parsed JSONL event object.
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
const data = (event as { data?: unknown }).data
if (typeof data !== 'object' || data === null) return
const carrier = data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
chunk?: { type?: unknown; block?: unknown }
}
collectImageRefs(carrier.content, refs)
if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
if (carrier.inserted !== undefined) {
for (const message of carrier.inserted) collectImageRefs(message.content, refs)
}
if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
}
/**
* Collect the distinct media references one stored artifact text names.
* Lines that fail to parse cannot reference media and are skipped (the
* artifact text itself is exported verbatim regardless).
* @param content - the stored artifact text.
* @returns the dedupe map keyed by attachment id.
*/
function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
const refs = new Map<string, ImageAttachmentRef>()
for (const line of content.split('\n')) {
if (line === '') continue
let event: unknown
try {
event = JSON.parse(line)
} catch {
continue
}
collectEventImageRefs(event, refs)
}
return refs
}
/**
* One safe zip path segment from an untrusted session id. Session ids are
* host-controlled, but the brand allows any non-empty string, so `../`, dot
* segments, and separator characters are neutralized before they can shape
* archive entries. Distinct ids may collapse onto one segment (id collision
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
* @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 flushed when live,
* read from the persistence backend right before it is yielded, and dropped
* after the consumer moves on), then every distinct media object referenced by any of
* the included logs (read and verified from the attachment store, one archive
* entry per attachment id). The host holds at most one descendant's artifact
* text and one media object at a time beyond the root.
* @param deps - the mounted export services (the caller answered 500 before this runs).
* @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 forwarded to lineage, persistence, and attachment reads.
* @returns the export entries in zip order.
*/
export async function* sessionLogZipEntries(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): AsyncGenerator<SessionLogZipEntry> {
const media = new Map<string, ImageAttachmentRef>()
const rememberMedia = (content: string): void => {
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
}
rememberMedia(root.content)
yield { path: root.filename, content: root.content }
if (includeDescendants) {
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)
await flushLiveSessionLog(deps, id, signal)
const raw = await deps.sessionPersistence.readRaw(id, signal)
signal?.throwIfAborted()
if (raw === undefined) {
throw new Error(`subagent "${id}" has no stored log artifact`)
}
rememberMedia(raw.content)
yield {
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
content: raw.content,
}
yield* collect(node.descendants)
}
}
const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
signal?.throwIfAborted()
yield* collect(lineage.descendants)
}
for (const ref of media.values()) {
signal?.throwIfAborted()
const stored = await deps.attachments.readImage(ref, signal)
signal?.throwIfAborted()
yield { path: mediaEntryPath(ref), data: stored.data }
}
}
/** How many code units of artifact text one zip push carries (bounded encode memory). */
const PUSH_CHUNK_CODE_UNITS = 1 << 16
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
const PUSH_CHUNK_BYTES = 1 << 16
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
/** One producer waiter released only when ReadableStream pull restores capacity. */
class ResponseCapacityGate {
private releasePending: (() => void) | undefined
/**
* Wait until the response queue has positive byte capacity or cancellation wins.
* @param controller - response controller whose desired size owns capacity.
* @param signal - combined request/consumer cancellation.
*/
async wait(
controller: ReadableStreamDefaultController<Uint8Array>,
signal: AbortSignal,
): Promise<void> {
signal.throwIfAborted()
if (controller.desiredSize === null || controller.desiredSize > 0) return
await new Promise<void>((resolve) => {
const release = (): void => {
this.releasePending = undefined
signal.removeEventListener('abort', release)
resolve()
}
this.releasePending = release
signal.addEventListener('abort', release, { once: true })
})
signal.throwIfAborted()
}
/** Release the current producer waiter after a consumer pull. */
pulled(): void {
this.releasePending?.()
}
}
/**
* Push one media object's bytes into a deflate stream in bounded chunks,
* waiting for consumer capacity between chunks like the artifact path does.
* @param deflate - the zip entry's deflate stream.
* @param data - the stored image bytes.
* @param controller - response queue controller.
* @param capacity - pull-driven response-capacity gate.
* @param signal - cancellation; throws when aborted.
*/
async function pushBinaryChunks(
deflate: ZipDeflate,
data: Uint8Array,
controller: ReadableStreamDefaultController<Uint8Array>,
capacity: ResponseCapacityGate,
signal: AbortSignal,
): Promise<void> {
let offset = 0
do {
signal.throwIfAborted()
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
const finalChunk = end >= data.byteLength
deflate.push(data.subarray(offset, end), finalChunk)
offset = end
await capacity.wait(controller, signal)
} while (offset < data.byteLength)
}
/**
* Push one artifact's text into a deflate stream in bounded chunks, never
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
* @param deflate - the zip entry's deflate stream.
* @param content - the artifact text verbatim.
* @param controller - response queue controller.
* @param capacity - pull-driven response-capacity gate.
* @param signal - cancellation; throws when aborted.
*/
async function pushArtifactChunks(
deflate: ZipDeflate,
content: string,
controller: ReadableStreamDefaultController<Uint8Array>,
capacity: ResponseCapacityGate,
signal: AbortSignal,
): Promise<void> {
const encoder = new TextEncoder()
let offset = 0
let finalChunk: boolean
do {
signal.throwIfAborted()
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
if (end < content.length && end - offset > 1) {
// Back off one code unit when the boundary lands inside a surrogate
// pair: the pair then starts the next chunk whole.
const last = content.charCodeAt(end - 1)
if (last >= 0xd800 && last <= 0xdbff) end -= 1
}
finalChunk = end >= content.length
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
offset = end
await capacity.wait(controller, signal)
} while (!finalChunk)
}
/**
* 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 mounted export services (the caller answered 500 before this runs).
* @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 compressionLevel - validated fflate DEFLATE level for every ZIP entry.
* @param signal - request cancellation combined with response-consumer cancellation.
* @returns the zip byte stream.
*/
export function streamSessionLogZip(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
compressionLevel: SessionLogCompressionLevel,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
const consumerAbort = new AbortController()
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
let zip: Zip | undefined
let zipTerminated = false
const capacity = new ResponseCapacityGate()
const terminateZip = (): void => {
if (zip === undefined || zipTerminated) return
zipTerminated = true
zip.terminate()
}
return new ReadableStream<Uint8Array>({
start(controller) {
// fflate invokes the callback synchronously per compressed chunk, so a
// single push can enqueue ahead of a slow consumer; the capacity gate
// waits for pull between pushes once the byte queue is full, bounding
// accumulation to the queue high-water mark plus one synchronous push.
const archive = new Zip((error, data, final) => {
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
if (error) {
controller.error(error)
return
}
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
zip = archive
void (async () => {
try {
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
archive.add(deflate)
if ('content' in entry) {
await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
} else {
await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
}
}
archive.end()
} catch (error) {
// A mid-stream failure (missing descendant, cancellation, read
// error) must fail the download rather than ship a truncated archive.
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
terminateZip()
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
pull() {
capacity.pulled()
},
cancel(reason) {
consumerAbort.abort(
reason instanceof Error ? reason : new Error('session log export stream cancelled'),
)
terminateZip()
},
}, {
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
size: chunk => chunk.byteLength,
})
}
@@ -1,10 +1,63 @@
/** Web Session-log download command over the host endpoint owned by ApiProxy. */
/** 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']
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',
@@ -12,10 +65,11 @@ const REQUESTED: CommandResult = {
}
/**
* Register the Web-only `/export` command that the browser download plugin observes.
* 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): void {
export function apply(ctx: Context, config: Config = {}): void {
ctx.effect(() => ctx.commands.register({
name: 'export',
description: 'Download this Session log as a ZIP archive',
@@ -23,4 +77,83 @@ export function apply(ctx: Context): void {
? 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: request => sessionLogExportResponse(
ctx,
request,
config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
),
})
}
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)}"`,
},
},
)
if (request.method === 'GET') return response
await response.body?.cancel()
return new Response(null, { status: response.status, headers: response.headers })
}
@@ -9,7 +9,10 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export'
export const name = 'session-export-invariant'
export const inject = ['invariants']
/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */
/**
* No runtime invariant: Connection and the command registry own both
* registrations, while each export reads authoritative Session services.
*/
const install: InvariantInstaller = () => {}
/**
@@ -13,6 +13,9 @@ describe('/export Web download command', () => {
return () => { descriptor = undefined }
},
} as never)
ctx.provide('connection', {
fetch: { register: () => () => Promise.resolve() },
} as never)
const fiber = await ctx.plugin(SessionLogDownload)
expect(descriptor).toMatchObject({
@@ -34,6 +34,9 @@ describe('session-log-download real Loader composition', () => {
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
context.provide('connection', {
fetch: { register: () => () => Promise.resolve() },
} as never)
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
@@ -0,0 +1,105 @@
import { Context } from '@deepseek-ai/cordis'
import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import { strFromU8, unzipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import {
Config,
SESSION_LOG_EXPORT_PATH,
apply,
inject,
} from '../src/index.ts'
const sid = (value: string): SessionId => value as SessionId
function artifact(id: string): SessionRawArtifact {
const header: SessionHeader = {
version: 0,
id: sid(id),
createdAt: 1,
cwd: '/workspace',
delegationDepth: 0,
}
return {
meta: header,
filename: 'session.jsonl',
content: `${JSON.stringify({ type: 'session', ...header })}\n`,
}
}
async function mounted(withServices: boolean): Promise<{
readonly connection: HostConnectionService
readonly dispose: () => Promise<void>
}> {
const ctx = new Context()
ctx.provide('commands', { register: () => () => {} } as never)
if (withServices) {
ctx.provide('sessionQuery', {
traceSession: async () => ({ descendants: [] }),
} as never)
ctx.provide('sessionPersistence', {
supportsRawArtifacts: true,
readRaw: async (id: SessionId) => artifact(String(id)),
} as never)
ctx.provide('attachments', {
readImage: async () => { throw new Error('fixture has no images') },
} as never)
}
const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber
return { connection, dispose: () => fiber.dispose() }
}
describe('Session log export Fetch route', () => {
it('registers one GET/HEAD route and removes it with the plugin fiber', async () => {
const { connection, dispose } = await mounted(true)
const fallback = { fetch: async () => new Response('fallback', { status: 418 }) }
const shared = connection.createSharedFetchHandler('/api', fallback)
const response = await shared.fetch(new Request(
`http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
))
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
const files = unzipSync(new Uint8Array(await response.arrayBuffer()))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"')
const head = await shared.fetch(new Request(
`http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' },
))
expect(head.status).toBe(200)
expect(head.body).toBeNull()
await dispose()
expect((await shared.fetch(new Request(
`http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
))).status).toBe(418)
})
it('validates the query before reporting missing export services', async () => {
const { connection, dispose } = await mounted(false)
const shared = connection.createSharedFetchHandler('/api', {
fetch: async () => new Response('fallback', { status: 418 }),
})
expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400)
expect((await shared.fetch(new Request(
`http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`,
))).status).toBe(400)
expect((await shared.fetch(new Request(
`http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`,
))).status).toBe(500)
await dispose()
})
it('validates the compression level', () => {
expect(Config({})).toEqual({ compressionLevel: 6 })
expect(Config({ compressionLevel: 0 })).toEqual({ compressionLevel: 0 })
expect(Config({ compressionLevel: 9 })).toEqual({ compressionLevel: 9 })
for (const compressionLevel of [-1, 10, 1.5]) {
expect(() => Config({ compressionLevel } as never)).toThrow()
}
})
})
@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/Dialog.tsx",
"src/client/HeaderAction.tsx",
"src/client/controller.ts",
"src/client/index.ts",
"src/client/locales.ts",
"src/css-modules.d.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../client/locale" },
{ "path": "../../client/store" },
{ "path": "../../client/ui-commands" },
{ "path": "../../client/ui-conversation" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-renderer" },
{ "path": "../../client/ui-session" },
{ "path": "../../client/ui-slots" },
{ "path": "../../core/session" }
]
}
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/archive.ts",
"src/index.ts",
"src/invariant.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../attachment/attachment" },
{ "path": "../../core/session" },
{ "path": "../../interaction/commands" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../session/session-persistence" },
{ "path": "../session-query" }
]
}
@@ -1,24 +1,7 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"files": [],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../interaction/commands" },
{ "path": "../../client/locale" },
{ "path": "../../client/store" },
{ "path": "../../core/session" },
{ "path": "../../client/ui-commands" },
{ "path": "../../client/ui-conversation" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-renderer" },
{ "path": "../../client/ui-session" },
{ "path": "../../client/ui-slots" },
{ "path": "../../runtime-diagnostics/invariants" }
{ "path": "./tsconfig.host.json" },
{ "path": "./tsconfig.client.json" }
]
}
@@ -1,3 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-session-log-export', ['lib/types/index.js', 'lib/types/invariant.js'])
export default clientBundle(
'@deepseek-ai/dsh-session-log-export',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)