mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(session-export): own the download route
This commit is contained in:
@@ -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 活动的根会话并流式传输 ZIP;ZIP 生成、原始 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 },
|
||||
)
|
||||
|
||||
Generated
+19
@@ -7047,6 +7047,13 @@ importers:
|
||||
version: link:../../subagent/subagent
|
||||
|
||||
packages/session-query/session-log-export:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
fflate:
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
@@ -7057,6 +7064,12 @@ importers:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/locale
|
||||
@@ -7090,6 +7103,12 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-persistence
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../session-query
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{ "path": "./packages/client/ui-settings-plugins" },
|
||||
{ "path": "./packages/client/ui-user-questions" },
|
||||
{ "path": "./packages/client/ui-trajectory" },
|
||||
{ "path": "./packages/session-query/session-log-export" },
|
||||
{ "path": "./packages/session-query/session-log-export/tsconfig.client.json" },
|
||||
{ "path": "./packages/client/ui-theme" },
|
||||
{ "path": "./packages/client/ui-settings" },
|
||||
{ "path": "./packages/client/ui-settings-general" },
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
{ "path": "./packages/session/session-stats" },
|
||||
{ "path": "./packages/session-query/session-query" },
|
||||
{ "path": "./packages/session-query/session-query-sqlite" },
|
||||
{ "path": "./packages/session-query/session-log-export/tsconfig.host.json" },
|
||||
{ "path": "./packages/settings/settings" },
|
||||
{ "path": "./packages/settings/settings-file" },
|
||||
{ "path": "./packages/credentials/credentials" },
|
||||
|
||||
Reference in New Issue
Block a user