@lingxi-ai-cn/dsh-session-export 0.1.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/README.zh.md +31 -0
- package/lib/index.js +536 -0
- package/lib/invariant.js +20 -0
- package/lib/types/index.d.ts +104 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/zip.d.ts +106 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `@lingxi-ai-cn/dsh-session-export`
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Host-owned Session-log ZIP producer and native path writer. The `SessionLogExporter` service (`ctx.sessionLogExporter`) flushes each live Session before reading its persistence backend's raw artifact, streams the root artifact, optional descendants, and referenced images through bounded fflate compression, and never reconstructs stored logs from parsed events. `stream()` exposes the archive to a host transport; `writeToDirectory()` writes the same archive to an existing absolute directory and returns its exact final path.
|
|
6
|
+
|
|
7
|
+
The native writer creates an owner-only random sibling, forwards cancellation through root preparation, lineage, persistence, attachment reads, compression, and file writes, syncs and closes the complete file, then publishes it with an exclusive hard link. Existing exports are never overwritten: the first collision uses `-2`, followed by increasing suffixes. A failure before publication removes the partial sibling and leaves no final archive. The destination filesystem must support same-directory hard links.
|
|
8
|
+
|
|
9
|
+
Expected preparation failures use `SessionLogExportError`: `services-unavailable`, `raw-artifacts-unsupported`, `session-not-found`, and `prepare-failed`. Destination and output failures use `destination-invalid` and `write-failed`. The operator-facing messages do not expose backend preparation errors; the original error remains attached as `cause` for host diagnostics. Signal cancellation preserves the signal's reason instead of becoming an export failure.
|
|
10
|
+
|
|
11
|
+
The archive is diagnostic material containing the stored Session artifacts verbatim. Provider credential stores and transient OAuth progress are outside Session persistence and are never included, but prompts and tool arguments already present in the durable log remain present; consumers must choose an operator-controlled destination and treat the result as sensitive.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
| Key | Default | Behavior |
|
|
16
|
+
|---|---:|---|
|
|
17
|
+
| `compressionLevel` | `6` | Integer fflate DEFLATE level from `0` (store) through `9` (smallest archive). |
|
|
18
|
+
|
|
19
|
+
## Model Experience
|
|
20
|
+
|
|
21
|
+
None, as export reads durable artifacts and writes a host file without adding Session events or model-visible content.
|
|
22
|
+
|
|
23
|
+
#### KV Cache effect
|
|
24
|
+
|
|
25
|
+
None. Export does not assemble or send provider requests.
|
|
26
|
+
|
|
27
|
+
## Known Limitations and Deferred Work
|
|
28
|
+
|
|
29
|
+
- Persistence backends that do not expose per-Session raw artifacts cannot export; the shipped JSONL backend supports plaintext and zstd artifacts, while SQLite does not.
|
|
30
|
+
- A tree export is a sequence of per-Session durability barriers and reads, not one atomic snapshot across the whole lineage; a live descendant may append after its artifact was read.
|
|
31
|
+
- Native publication requires hard-link support in the selected directory. The service fails without publishing a partial final file when the filesystem rejects that operation.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `@lingxi-ai-cn/dsh-session-export`
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
宿主侧拥有的 Session 日志 ZIP 生产器与原生路径写入器。`SessionLogExporter` 服务(`ctx.sessionLogExporter`)会在读取持久化后端的原始工件前 flush 每个实时 Session,通过有界 fflate 压缩流式输出根工件、可选后代和引用的图片,并且绝不从解析后的事件重建存储日志。`stream()` 向宿主传输层提供归档;`writeToDirectory()` 把同一归档写入一个既有的绝对目录,并返回精确的最终路径。
|
|
6
|
+
|
|
7
|
+
原生写入器先创建一个仅所有者可读写的随机同级文件,把取消转发到根工件准备、血缘、持久化、附件读取、压缩和文件写入,随后同步并关闭完整文件,最后通过排他硬链接发布。既有导出永远不会被覆盖:第一次冲突使用 `-2`,随后递增后缀。发布前的失败会删除局部同级文件,不留下最终归档。目标文件系统必须支持同目录硬链接。
|
|
8
|
+
|
|
9
|
+
预期的准备失败使用 `SessionLogExportError`:`services-unavailable`、`raw-artifacts-unsupported`、`session-not-found` 和 `prepare-failed`。目标与输出失败使用 `destination-invalid` 和 `write-failed`。面向操作者的消息不会泄露后端准备错误;原始错误保留为 `cause`,供宿主诊断。signal 取消会保留 signal 的 reason,而不会改写成导出失败。
|
|
10
|
+
|
|
11
|
+
该归档是逐字包含所存 Session 工件的诊断材料。提供方凭据存储和 transient OAuth 进度位于 Session 持久化之外,绝不会包含在内;但已经进入持久日志的 prompt 与工具参数仍会保留,因此 Consumer 必须选择由操作者控制的目标位置,并把结果视为敏感材料。
|
|
12
|
+
|
|
13
|
+
## 配置
|
|
14
|
+
|
|
15
|
+
| 键 | 默认值 | 行为 |
|
|
16
|
+
|---|---:|---|
|
|
17
|
+
| `compressionLevel` | `6` | 从 `0`(仅存储)到 `9`(最小归档)的整数 fflate DEFLATE 级别。 |
|
|
18
|
+
|
|
19
|
+
## 模型体验
|
|
20
|
+
|
|
21
|
+
无,因为导出只读取持久工件并写入宿主文件,不增加 Session 事件或模型可见内容。
|
|
22
|
+
|
|
23
|
+
#### KV Cache 影响
|
|
24
|
+
|
|
25
|
+
无。导出不会组装或发送提供方请求。
|
|
26
|
+
|
|
27
|
+
## 已知限制与暂缓事项
|
|
28
|
+
|
|
29
|
+
- 不提供逐 Session 原始工件的持久化后端无法导出;随附的 JSONL 后端支持明文与 zstd 工件,SQLite 尚不支持。
|
|
30
|
+
- 树导出是一系列逐 Session 的持久性屏障与读取,并非覆盖整条血缘的一次原子快照;实时后代可能在自身工件被读取后继续追加。
|
|
31
|
+
- 原生发布要求所选目录支持硬链接。文件系统拒绝该操作时,服务会失败且不会发布局部最终文件。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, open, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { Zip, ZipDeflate } from "fflate";
|
|
7
|
+
//#region lib/types/zip.js
|
|
8
|
+
/**
|
|
9
|
+
* Host-side Session ZIP producer: streams one archive whose files are the
|
|
10
|
+
* sessions' stored artifact text verbatim plus every referenced media object.
|
|
11
|
+
* The root artifact sits under its original base name (`session.jsonl`); each
|
|
12
|
+
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
|
13
|
+
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
|
14
|
+
* so one archive never duplicates a shared image). No manifest is written —
|
|
15
|
+
* every file is byte-identical to the backend's durable artifact or attachment
|
|
16
|
+
* store and self-describing through its own header line or media type. Before
|
|
17
|
+
* each live session's artifact read, the SessionStore flush barrier makes the
|
|
18
|
+
* current in-memory log durable; cold sessions need no barrier. Request abort
|
|
19
|
+
* and response-consumer cancellation share one producer signal and terminate
|
|
20
|
+
* the active compressor.
|
|
21
|
+
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
|
22
|
+
* bytes are produced incrementally and the host never holds the whole archive
|
|
23
|
+
* in one buffer; production waits for consumer pull whenever the response queue
|
|
24
|
+
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
|
25
|
+
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
|
26
|
+
* @module @lingxi-ai-cn/dsh-session-export/zip
|
|
27
|
+
*/
|
|
28
|
+
/** Balanced default used when a deployment omits compression config. */
|
|
29
|
+
const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL = 6;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the persistence, session-query, and attachment services a log export needs.
|
|
32
|
+
* @param ctx - the composed host context.
|
|
33
|
+
* @returns the export services (absent when the deployment does not mount them).
|
|
34
|
+
*/
|
|
35
|
+
function sessionLogExportDeps(ctx) {
|
|
36
|
+
return {
|
|
37
|
+
sessionQuery: ctx.get("sessionQuery"),
|
|
38
|
+
sessionPersistence: ctx.get("sessionPersistence"),
|
|
39
|
+
attachments: ctx.get("attachments"),
|
|
40
|
+
sessions: ctx.get("sessions")
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Flush one currently live session through the store's authoritative durability
|
|
45
|
+
* barrier immediately before its raw artifact is read. A cold or absent id has
|
|
46
|
+
* no in-memory work to flush.
|
|
47
|
+
* @param deps - export services, including the optional live-session store.
|
|
48
|
+
* @param id - the session whose artifact is about to be read.
|
|
49
|
+
* @param signal - optional cancellation observed around the flush barrier.
|
|
50
|
+
*/
|
|
51
|
+
async function flushLiveSessionLog(deps, id, signal) {
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
const sessions = deps.sessions;
|
|
54
|
+
if (sessions === void 0) return;
|
|
55
|
+
const session = sessions.get(id);
|
|
56
|
+
if (session === void 0) return;
|
|
57
|
+
await sessions.flush(session);
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
}
|
|
60
|
+
/** Zip extension for each accepted raster media type. */
|
|
61
|
+
const MEDIA_TYPE_EXTENSIONS = {
|
|
62
|
+
"image/png": "png",
|
|
63
|
+
"image/jpeg": "jpg",
|
|
64
|
+
"image/webp": "webp",
|
|
65
|
+
"image/gif": "gif"
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The zip path for one media object: content-addressed by the opaque
|
|
69
|
+
* attachment id so shared images land once and the id in the log maps back to
|
|
70
|
+
* the archive entry without a manifest.
|
|
71
|
+
* @param ref - the durable reference from a session log.
|
|
72
|
+
* @returns the archive path.
|
|
73
|
+
*/
|
|
74
|
+
function mediaEntryPath(ref) {
|
|
75
|
+
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Collect every image reference inside one content array, descending into
|
|
79
|
+
* nested tool results the way the live attachment route does.
|
|
80
|
+
* @param content - an event content array (or nested tool-result content).
|
|
81
|
+
* @param refs - the dedupe map being filled (keyed by attachment id).
|
|
82
|
+
*/
|
|
83
|
+
function collectImageRefs(content, refs) {
|
|
84
|
+
if (!Array.isArray(content)) return;
|
|
85
|
+
const pending = [];
|
|
86
|
+
for (const item of content) pending.push(item);
|
|
87
|
+
while (pending.length > 0) {
|
|
88
|
+
const value = pending.pop();
|
|
89
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
90
|
+
const block = value;
|
|
91
|
+
if (block.type === "image" && typeof block.attachment === "object" && block.attachment !== null) {
|
|
92
|
+
const ref = block.attachment;
|
|
93
|
+
refs.set(String(ref.attachmentId), ref);
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(block.content)) for (const item of block.content) pending.push(item);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Collect every image reference one session event carries, across the same
|
|
100
|
+
* carriers the live attachment route scans (direct content, message content,
|
|
101
|
+
* inserted messages, and completed assistant chunk blocks).
|
|
102
|
+
* @param event - one parsed JSONL event object.
|
|
103
|
+
* @param refs - the dedupe map being filled (keyed by attachment id).
|
|
104
|
+
*/
|
|
105
|
+
function collectEventImageRefs(event, refs) {
|
|
106
|
+
const data = event.data;
|
|
107
|
+
if (typeof data !== "object" || data === null) return;
|
|
108
|
+
const carrier = data;
|
|
109
|
+
collectImageRefs(carrier.content, refs);
|
|
110
|
+
if (carrier.message !== void 0) collectImageRefs(carrier.message.content, refs);
|
|
111
|
+
if (carrier.inserted !== void 0) for (const message of carrier.inserted) collectImageRefs(message.content, refs);
|
|
112
|
+
if (carrier.chunk?.type === "block-end") collectImageRefs([carrier.chunk.block], refs);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Collect the distinct media references one stored artifact text names.
|
|
116
|
+
* Lines that fail to parse cannot reference media and are skipped (the
|
|
117
|
+
* artifact text itself is exported verbatim regardless).
|
|
118
|
+
* @param content - the stored artifact text.
|
|
119
|
+
* @returns the dedupe map keyed by attachment id.
|
|
120
|
+
*/
|
|
121
|
+
function imageRefsInArtifact(content) {
|
|
122
|
+
const refs = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const line of content.split("\n")) {
|
|
124
|
+
if (line === "") continue;
|
|
125
|
+
let event;
|
|
126
|
+
try {
|
|
127
|
+
event = JSON.parse(line);
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
collectEventImageRefs(event, refs);
|
|
132
|
+
}
|
|
133
|
+
return refs;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* One safe zip path segment from an untrusted session id. Session ids are
|
|
137
|
+
* host-controlled, but the brand allows any non-empty string, so `../`, dot
|
|
138
|
+
* segments, and separator characters are neutralized before they can shape
|
|
139
|
+
* archive entries. Distinct ids may collapse onto one segment (id collision
|
|
140
|
+
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
|
|
141
|
+
* @param id - the raw session id.
|
|
142
|
+
* @returns a filesystem-safe single path segment.
|
|
143
|
+
*/
|
|
144
|
+
function safeSessionIdSegment(id) {
|
|
145
|
+
return id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The export archive filename for one root session.
|
|
149
|
+
* @param sessionId - the root session id (sanitized to one safe path segment).
|
|
150
|
+
* @returns the attachment filename for the session's export archive.
|
|
151
|
+
*/
|
|
152
|
+
function sessionLogZipFilename(sessionId) {
|
|
153
|
+
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Yield the export entries in zip order: the preloaded root artifact first,
|
|
157
|
+
* then every subagent descendant in lineage order (each flushed when live,
|
|
158
|
+
* read from the persistence backend right before it is yielded, and dropped
|
|
159
|
+
* after the consumer moves on), then every distinct media object referenced by any of
|
|
160
|
+
* the included logs (read and verified from the attachment store, one archive
|
|
161
|
+
* entry per attachment id). The host holds at most one descendant's artifact
|
|
162
|
+
* text and one media object at a time beyond the root.
|
|
163
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
164
|
+
* @param root - the already-read root artifact (read by the caller so the
|
|
165
|
+
* missing-session path can answer cleanly before streaming starts).
|
|
166
|
+
* @param sessionId - the root session id.
|
|
167
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
168
|
+
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
|
169
|
+
* @returns the export entries in zip order.
|
|
170
|
+
*/
|
|
171
|
+
async function* sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal) {
|
|
172
|
+
const media = /* @__PURE__ */ new Map();
|
|
173
|
+
const rememberMedia = (content) => {
|
|
174
|
+
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref);
|
|
175
|
+
};
|
|
176
|
+
rememberMedia(root.content);
|
|
177
|
+
yield {
|
|
178
|
+
path: root.filename,
|
|
179
|
+
content: root.content
|
|
180
|
+
};
|
|
181
|
+
if (includeDescendants) {
|
|
182
|
+
const seen = /* @__PURE__ */ new Set([sessionId]);
|
|
183
|
+
const collect = async function* (nodes) {
|
|
184
|
+
for (const node of nodes) {
|
|
185
|
+
signal?.throwIfAborted();
|
|
186
|
+
const id = node.session.header.id;
|
|
187
|
+
if (seen.has(id)) continue;
|
|
188
|
+
seen.add(id);
|
|
189
|
+
await flushLiveSessionLog(deps, id, signal);
|
|
190
|
+
const raw = await deps.sessionPersistence.readRaw(id, signal);
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
if (raw === void 0) throw new Error(`subagent "${id}" has no stored log artifact`);
|
|
193
|
+
rememberMedia(raw.content);
|
|
194
|
+
yield {
|
|
195
|
+
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
|
|
196
|
+
content: raw.content
|
|
197
|
+
};
|
|
198
|
+
yield* collect(node.descendants);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const lineage = await deps.sessionQuery.traceSession(sessionId, signal);
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
yield* collect(lineage.descendants);
|
|
204
|
+
}
|
|
205
|
+
for (const ref of media.values()) {
|
|
206
|
+
signal?.throwIfAborted();
|
|
207
|
+
const stored = await deps.attachments.readImage(ref, signal);
|
|
208
|
+
signal?.throwIfAborted();
|
|
209
|
+
yield {
|
|
210
|
+
path: mediaEntryPath(ref),
|
|
211
|
+
data: stored.data
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** How many code units of artifact text one zip push carries (bounded encode memory). */
|
|
216
|
+
const PUSH_CHUNK_CODE_UNITS = 65536;
|
|
217
|
+
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
|
218
|
+
const PUSH_CHUNK_BYTES = 65536;
|
|
219
|
+
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
|
|
220
|
+
const RESPONSE_HIGH_WATER_MARK_BYTES = 65536;
|
|
221
|
+
/** One producer waiter released only when ReadableStream pull restores capacity. */
|
|
222
|
+
var ResponseCapacityGate = class {
|
|
223
|
+
releasePending;
|
|
224
|
+
/**
|
|
225
|
+
* Wait until the response queue has positive byte capacity or cancellation wins.
|
|
226
|
+
* @param controller - response controller whose desired size owns capacity.
|
|
227
|
+
* @param signal - combined request/consumer cancellation.
|
|
228
|
+
*/
|
|
229
|
+
async wait(controller, signal) {
|
|
230
|
+
signal.throwIfAborted();
|
|
231
|
+
if (controller.desiredSize === null || controller.desiredSize > 0) return;
|
|
232
|
+
await new Promise((resolve) => {
|
|
233
|
+
const release = () => {
|
|
234
|
+
this.releasePending = void 0;
|
|
235
|
+
signal.removeEventListener("abort", release);
|
|
236
|
+
resolve();
|
|
237
|
+
};
|
|
238
|
+
this.releasePending = release;
|
|
239
|
+
signal.addEventListener("abort", release, { once: true });
|
|
240
|
+
});
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
}
|
|
243
|
+
/** Release the current producer waiter after a consumer pull. */
|
|
244
|
+
pulled() {
|
|
245
|
+
this.releasePending?.();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Push one media object's bytes into a deflate stream in bounded chunks,
|
|
250
|
+
* waiting for consumer capacity between chunks like the artifact path does.
|
|
251
|
+
* @param deflate - the zip entry's deflate stream.
|
|
252
|
+
* @param data - the stored image bytes.
|
|
253
|
+
* @param controller - response queue controller.
|
|
254
|
+
* @param capacity - pull-driven response-capacity gate.
|
|
255
|
+
* @param signal - cancellation; throws when aborted.
|
|
256
|
+
*/
|
|
257
|
+
async function pushBinaryChunks(deflate, data, controller, capacity, signal) {
|
|
258
|
+
let offset = 0;
|
|
259
|
+
do {
|
|
260
|
+
signal.throwIfAborted();
|
|
261
|
+
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength);
|
|
262
|
+
const finalChunk = end >= data.byteLength;
|
|
263
|
+
deflate.push(data.subarray(offset, end), finalChunk);
|
|
264
|
+
offset = end;
|
|
265
|
+
await capacity.wait(controller, signal);
|
|
266
|
+
} while (offset < data.byteLength);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Push one artifact's text into a deflate stream in bounded chunks, never
|
|
270
|
+
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
|
|
271
|
+
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
|
272
|
+
* @param deflate - the zip entry's deflate stream.
|
|
273
|
+
* @param content - the artifact text verbatim.
|
|
274
|
+
* @param controller - response queue controller.
|
|
275
|
+
* @param capacity - pull-driven response-capacity gate.
|
|
276
|
+
* @param signal - cancellation; throws when aborted.
|
|
277
|
+
*/
|
|
278
|
+
async function pushArtifactChunks(deflate, content, controller, capacity, signal) {
|
|
279
|
+
const encoder = new TextEncoder();
|
|
280
|
+
let offset = 0;
|
|
281
|
+
let finalChunk;
|
|
282
|
+
do {
|
|
283
|
+
signal.throwIfAborted();
|
|
284
|
+
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length);
|
|
285
|
+
if (end < content.length && end - offset > 1) {
|
|
286
|
+
const last = content.charCodeAt(end - 1);
|
|
287
|
+
if (last >= 55296 && last <= 56319) end -= 1;
|
|
288
|
+
}
|
|
289
|
+
finalChunk = end >= content.length;
|
|
290
|
+
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk);
|
|
291
|
+
offset = end;
|
|
292
|
+
await capacity.wait(controller, signal);
|
|
293
|
+
} while (!finalChunk);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
|
297
|
+
* read and validated by the caller before this is called (missing root or
|
|
298
|
+
* missing services answer cleanly before any byte is produced); each entry is
|
|
299
|
+
* then encoded and deflated in bounded chunks as it is produced, so the
|
|
300
|
+
* archive bytes arrive incrementally. A descendant that fails to read errors
|
|
301
|
+
* the stream (fail-loud, never silent under-export).
|
|
302
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
303
|
+
* @param root - the already-read root artifact (first zip entry).
|
|
304
|
+
* @param sessionId - the root session id.
|
|
305
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
306
|
+
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
|
307
|
+
* @param signal - request cancellation combined with response-consumer cancellation.
|
|
308
|
+
* @returns the zip byte stream.
|
|
309
|
+
*/
|
|
310
|
+
function streamSessionLogZip(deps, root, sessionId, includeDescendants, compressionLevel, signal) {
|
|
311
|
+
const consumerAbort = new AbortController();
|
|
312
|
+
const producerSignal = AbortSignal.any([signal, consumerAbort.signal]);
|
|
313
|
+
let zip;
|
|
314
|
+
let zipTerminated = false;
|
|
315
|
+
const capacity = new ResponseCapacityGate();
|
|
316
|
+
const terminateZip = () => {
|
|
317
|
+
if (zip === void 0 || zipTerminated) return;
|
|
318
|
+
zipTerminated = true;
|
|
319
|
+
zip.terminate();
|
|
320
|
+
};
|
|
321
|
+
return new ReadableStream({
|
|
322
|
+
start(controller) {
|
|
323
|
+
const archive = new Zip((error, data, final) => {
|
|
324
|
+
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
|
325
|
+
if (error) {
|
|
326
|
+
controller.error(error);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
|
|
330
|
+
if (data.byteLength > 0) controller.enqueue(data);
|
|
331
|
+
if (final) controller.close();
|
|
332
|
+
});
|
|
333
|
+
zip = archive;
|
|
334
|
+
(async () => {
|
|
335
|
+
try {
|
|
336
|
+
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
|
|
337
|
+
const deflate = new ZipDeflate(entry.path, { level: compressionLevel });
|
|
338
|
+
archive.add(deflate);
|
|
339
|
+
if ("content" in entry) await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal);
|
|
340
|
+
else await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal);
|
|
341
|
+
}
|
|
342
|
+
archive.end();
|
|
343
|
+
} catch (error) {
|
|
344
|
+
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
|
345
|
+
terminateZip();
|
|
346
|
+
controller.error(error instanceof Error ? error : new Error(String(error)));
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
},
|
|
350
|
+
pull() {
|
|
351
|
+
capacity.pulled();
|
|
352
|
+
},
|
|
353
|
+
cancel(reason) {
|
|
354
|
+
consumerAbort.abort(reason instanceof Error ? reason : /* @__PURE__ */ new Error("session log export stream cancelled"));
|
|
355
|
+
terminateZip();
|
|
356
|
+
}
|
|
357
|
+
}, {
|
|
358
|
+
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
|
|
359
|
+
size: (chunk) => chunk.byteLength
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region lib/types/index.js
|
|
364
|
+
/**
|
|
365
|
+
* Host session-log export service: prepares the canonical stored-artifact ZIP,
|
|
366
|
+
* exposes it as a bounded byte stream, and publishes complete archives into a
|
|
367
|
+
* host directory without exposing a partial final file.
|
|
368
|
+
* @module @lingxi-ai-cn/dsh-session-export
|
|
369
|
+
*/
|
|
370
|
+
/** Typed session-log export failure with a consumer-safe message. */
|
|
371
|
+
var SessionLogExportError = class extends Error {
|
|
372
|
+
code;
|
|
373
|
+
/**
|
|
374
|
+
* @param code - stable failure category.
|
|
375
|
+
* @param message - safe operator-facing description.
|
|
376
|
+
* @param options - optional private cause retained for host diagnostics.
|
|
377
|
+
*/
|
|
378
|
+
constructor(code, message, options) {
|
|
379
|
+
super(message, options);
|
|
380
|
+
this.code = code;
|
|
381
|
+
this.name = "SessionLogExportError";
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Flush and read the root artifact before any ZIP byte is produced. Expected
|
|
386
|
+
* capability and persistence failures use {@link SessionLogExportError}; an
|
|
387
|
+
* aborted signal preserves its original reason.
|
|
388
|
+
* @param ctx - composed host context.
|
|
389
|
+
* @param sessionId - root Session identity.
|
|
390
|
+
* @param signal - operation cancellation.
|
|
391
|
+
* @returns the mounted services and root artifact ready for streaming.
|
|
392
|
+
*/
|
|
393
|
+
async function prepareSessionLogExport(ctx, sessionId, signal) {
|
|
394
|
+
signal.throwIfAborted();
|
|
395
|
+
const deps = sessionLogExportDeps(ctx);
|
|
396
|
+
if (deps.sessionQuery === void 0 || deps.sessionPersistence === void 0 || deps.attachments === void 0) throw new SessionLogExportError("services-unavailable", "session log export is unavailable: missing session-query, session-persistence, or attachments service");
|
|
397
|
+
if (!deps.sessionPersistence.supportsRawArtifacts) throw new SessionLogExportError("raw-artifacts-unsupported", "session log export is unavailable: the persistence backend does not expose per-session raw artifacts");
|
|
398
|
+
const ready = {
|
|
399
|
+
sessionQuery: deps.sessionQuery,
|
|
400
|
+
sessionPersistence: deps.sessionPersistence,
|
|
401
|
+
attachments: deps.attachments,
|
|
402
|
+
sessions: deps.sessions
|
|
403
|
+
};
|
|
404
|
+
let root;
|
|
405
|
+
try {
|
|
406
|
+
await flushLiveSessionLog(deps, sessionId, signal);
|
|
407
|
+
root = await deps.sessionPersistence.readRaw(sessionId, signal);
|
|
408
|
+
signal.throwIfAborted();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
signal.throwIfAborted();
|
|
411
|
+
throw new SessionLogExportError("prepare-failed", "session log export failed to prepare the stored artifact", { cause: error });
|
|
412
|
+
}
|
|
413
|
+
if (root === void 0) throw new SessionLogExportError("session-not-found", "session not found");
|
|
414
|
+
return {
|
|
415
|
+
ready,
|
|
416
|
+
root
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** Write a complete chunk even when the host returns a short write. */
|
|
420
|
+
async function writeChunk(file, chunk, signal) {
|
|
421
|
+
let offset = 0;
|
|
422
|
+
while (offset < chunk.byteLength) {
|
|
423
|
+
signal.throwIfAborted();
|
|
424
|
+
const { bytesWritten } = await file.write(chunk, offset, chunk.byteLength - offset);
|
|
425
|
+
if (bytesWritten === 0) throw new Error("session log export made no progress writing the archive");
|
|
426
|
+
offset += bytesWritten;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Candidate filename that retains the extension while avoiding an existing export. */
|
|
430
|
+
function collisionFilename(filename, collision) {
|
|
431
|
+
if (collision === 0) return filename;
|
|
432
|
+
const extension = extname(filename);
|
|
433
|
+
return `${basename(filename, extension)}-${collision + 1}${extension}`;
|
|
434
|
+
}
|
|
435
|
+
/** Publish a completed private temp inode under the first available final name. */
|
|
436
|
+
async function publishExclusive(tempPath, directory, filename, signal) {
|
|
437
|
+
for (let collision = 0; collision < 1e4; collision += 1) {
|
|
438
|
+
signal.throwIfAborted();
|
|
439
|
+
const candidate = collisionFilename(filename, collision);
|
|
440
|
+
const target = join(directory, candidate);
|
|
441
|
+
try {
|
|
442
|
+
await link(tempPath, target);
|
|
443
|
+
try {
|
|
444
|
+
await rm(tempPath);
|
|
445
|
+
} catch {}
|
|
446
|
+
return {
|
|
447
|
+
path: target,
|
|
448
|
+
filename: candidate
|
|
449
|
+
};
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error.code === "EEXIST") continue;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`session log export exhausted collision suffixes for ${filename}`);
|
|
456
|
+
}
|
|
457
|
+
/** Host service shared by terminal and programmatic Session export consumers. */
|
|
458
|
+
var SessionLogExporter = class extends Service {
|
|
459
|
+
static inject = [
|
|
460
|
+
"attachments",
|
|
461
|
+
"sessionPersistence",
|
|
462
|
+
"sessionQuery",
|
|
463
|
+
"sessions"
|
|
464
|
+
];
|
|
465
|
+
static Config = z.object({ compressionLevel: z.number().step(1).min(0).max(9).default(6) });
|
|
466
|
+
compressionLevel;
|
|
467
|
+
constructor(ctx, config) {
|
|
468
|
+
super(ctx, "sessionLogExporter");
|
|
469
|
+
this.compressionLevel = config.compressionLevel ?? 6;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Prepare and expose one bounded ZIP byte stream.
|
|
473
|
+
* @param request - root identity and descendant policy.
|
|
474
|
+
* @param signal - producer lifetime; consumer cancellation is combined with it.
|
|
475
|
+
* @returns suggested filename and archive stream after root preflight succeeds.
|
|
476
|
+
*/
|
|
477
|
+
async stream(request, signal) {
|
|
478
|
+
const prepared = await prepareSessionLogExport(this.ctx, request.sessionId, signal);
|
|
479
|
+
return {
|
|
480
|
+
filename: sessionLogZipFilename(request.sessionId),
|
|
481
|
+
stream: streamSessionLogZip(prepared.ready, prepared.root, request.sessionId, request.includeDescendants, this.compressionLevel, signal)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Stream one archive to a private sibling file and publish it atomically under
|
|
486
|
+
* the first available safe filename. Existing exports are never overwritten.
|
|
487
|
+
* The destination must be an existing absolute directory. Cancellation or a
|
|
488
|
+
* producer/write failure removes the partial file and publishes nothing.
|
|
489
|
+
* @param request - root identity and descendant policy.
|
|
490
|
+
* @param directory - existing absolute host directory chosen by the operator.
|
|
491
|
+
* @param signal - complete preparation, streaming, and publication lifetime.
|
|
492
|
+
* @returns the exact published path and filename.
|
|
493
|
+
*/
|
|
494
|
+
async writeToDirectory(request, directory, signal) {
|
|
495
|
+
signal.throwIfAborted();
|
|
496
|
+
if (!isAbsolute(directory)) throw new SessionLogExportError("destination-invalid", "session log export destination must be an absolute directory");
|
|
497
|
+
try {
|
|
498
|
+
if (!(await stat(directory)).isDirectory()) throw new Error("destination is not a directory");
|
|
499
|
+
} catch (error) {
|
|
500
|
+
throw new SessionLogExportError("destination-invalid", `session log export destination is not an accessible directory: ${directory}`, { cause: error });
|
|
501
|
+
}
|
|
502
|
+
const prepared = await this.stream(request, signal);
|
|
503
|
+
const tempPath = join(directory, `.${prepared.filename}.${randomUUID()}.tmp`);
|
|
504
|
+
let file;
|
|
505
|
+
const reader = prepared.stream.getReader();
|
|
506
|
+
try {
|
|
507
|
+
file = await open(tempPath, "wx", 384);
|
|
508
|
+
for (;;) {
|
|
509
|
+
signal.throwIfAborted();
|
|
510
|
+
const chunk = await reader.read();
|
|
511
|
+
if (chunk.done) break;
|
|
512
|
+
await writeChunk(file, chunk.value, signal);
|
|
513
|
+
}
|
|
514
|
+
signal.throwIfAborted();
|
|
515
|
+
await file.sync();
|
|
516
|
+
await file.close();
|
|
517
|
+
file = void 0;
|
|
518
|
+
return await publishExclusive(tempPath, directory, prepared.filename, signal);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
try {
|
|
521
|
+
await reader.cancel(error);
|
|
522
|
+
} catch {}
|
|
523
|
+
try {
|
|
524
|
+
await file?.close();
|
|
525
|
+
} catch {}
|
|
526
|
+
await rm(tempPath, { force: true });
|
|
527
|
+
signal.throwIfAborted();
|
|
528
|
+
if (error instanceof SessionLogExportError) throw error;
|
|
529
|
+
throw new SessionLogExportError("write-failed", "session log export failed while writing the archive", { cause: error });
|
|
530
|
+
} finally {
|
|
531
|
+
reader.releaseLock();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
//#endregion
|
|
536
|
+
export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, SessionLogExportError, SessionLogExporter as default, flushLiveSessionLog, prepareSessionLogExport, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/** Package-owned invariant companion for host Session export. @module @lingxi-ai-cn/dsh-session-export/invariant */
|
|
3
|
+
const PACKAGE_NAME = "@lingxi-ai-cn/dsh-session-export";
|
|
4
|
+
/** Cordis companion plugin name. */
|
|
5
|
+
const name = "host-session-export-invariant";
|
|
6
|
+
/** Service required before the companion can reserve package ownership. */
|
|
7
|
+
const inject = ["invariants"];
|
|
8
|
+
/**
|
|
9
|
+
* No runtime invariant: export observes owning persistence, lineage, attachment,
|
|
10
|
+
* and Session flush APIs and emits no durable or live events of its own.
|
|
11
|
+
*/
|
|
12
|
+
const install = () => {};
|
|
13
|
+
/**
|
|
14
|
+
* Register the package invariant companion.
|
|
15
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
16
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
17
|
+
*/
|
|
18
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
19
|
+
//#endregion
|
|
20
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host session-log export service: prepares the canonical stored-artifact ZIP,
|
|
3
|
+
* exposes it as a bounded byte stream, and publishes complete archives into a
|
|
4
|
+
* host directory without exposing a partial final file.
|
|
5
|
+
* @module @lingxi-ai-cn/dsh-session-export
|
|
6
|
+
*/
|
|
7
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
import type { SessionId } from '@deepseek-ai/dsh-session';
|
|
10
|
+
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence';
|
|
11
|
+
import { type SessionLogCompressionLevel, type SessionLogExportReady } from './zip.ts';
|
|
12
|
+
export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip, } from './zip.ts';
|
|
13
|
+
export type { SessionLogCompressionLevel, SessionLogExportDeps, SessionLogExportReady, SessionLogZipEntry, } from './zip.ts';
|
|
14
|
+
/** Stable failure categories shared by the native writer and host transports. */
|
|
15
|
+
export type SessionLogExportErrorCode = 'services-unavailable' | 'raw-artifacts-unsupported' | 'session-not-found' | 'prepare-failed' | 'destination-invalid' | 'write-failed';
|
|
16
|
+
/** Typed session-log export failure with a consumer-safe message. */
|
|
17
|
+
export declare class SessionLogExportError extends Error {
|
|
18
|
+
readonly code: SessionLogExportErrorCode;
|
|
19
|
+
/**
|
|
20
|
+
* @param code - stable failure category.
|
|
21
|
+
* @param message - safe operator-facing description.
|
|
22
|
+
* @param options - optional private cause retained for host diagnostics.
|
|
23
|
+
*/
|
|
24
|
+
constructor(code: SessionLogExportErrorCode, message: string, options?: ErrorOptions);
|
|
25
|
+
}
|
|
26
|
+
/** Prepared root artifact and mounted services for one export operation. */
|
|
27
|
+
export interface PreparedSessionLogExport {
|
|
28
|
+
/** Mounted services used while descendant and attachment entries stream. */
|
|
29
|
+
readonly ready: SessionLogExportReady;
|
|
30
|
+
/** Root artifact already flushed and read for the first ZIP entry. */
|
|
31
|
+
readonly root: SessionRawArtifact;
|
|
32
|
+
}
|
|
33
|
+
/** One export request independent of its transport or destination. */
|
|
34
|
+
export interface SessionLogExportRequest {
|
|
35
|
+
/** Root Session whose stored artifact is exported. */
|
|
36
|
+
readonly sessionId: SessionId;
|
|
37
|
+
/** Whether all durable descendant artifacts are included. */
|
|
38
|
+
readonly includeDescendants: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Prepared streaming result for a transport consumer. */
|
|
41
|
+
export interface SessionLogExportStream {
|
|
42
|
+
/** Safe suggested archive filename. */
|
|
43
|
+
readonly filename: string;
|
|
44
|
+
/** Bounded ZIP byte stream; cancellation aborts the producer. */
|
|
45
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
46
|
+
}
|
|
47
|
+
/** Completed native export result. */
|
|
48
|
+
export interface SessionLogExportFile {
|
|
49
|
+
/** Absolute path atomically published after the ZIP completed. */
|
|
50
|
+
readonly path: string;
|
|
51
|
+
/** Published base filename, including a collision suffix when required. */
|
|
52
|
+
readonly filename: string;
|
|
53
|
+
}
|
|
54
|
+
/** Host session-log exporter configuration. */
|
|
55
|
+
export interface Config {
|
|
56
|
+
/**
|
|
57
|
+
* DEFLATE level for ZIP entries. Zero stores, one favors latency, and nine
|
|
58
|
+
* favors archive size.
|
|
59
|
+
* @default 6
|
|
60
|
+
*/
|
|
61
|
+
readonly compressionLevel?: SessionLogCompressionLevel;
|
|
62
|
+
}
|
|
63
|
+
declare module '@deepseek-ai/cordis' {
|
|
64
|
+
interface Context {
|
|
65
|
+
/** Host-owned Session ZIP stream and native file writer. */
|
|
66
|
+
sessionLogExporter: SessionLogExporter;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Flush and read the root artifact before any ZIP byte is produced. Expected
|
|
71
|
+
* capability and persistence failures use {@link SessionLogExportError}; an
|
|
72
|
+
* aborted signal preserves its original reason.
|
|
73
|
+
* @param ctx - composed host context.
|
|
74
|
+
* @param sessionId - root Session identity.
|
|
75
|
+
* @param signal - operation cancellation.
|
|
76
|
+
* @returns the mounted services and root artifact ready for streaming.
|
|
77
|
+
*/
|
|
78
|
+
export declare function prepareSessionLogExport(ctx: Context, sessionId: SessionId, signal: AbortSignal): Promise<PreparedSessionLogExport>;
|
|
79
|
+
/** Host service shared by terminal and programmatic Session export consumers. */
|
|
80
|
+
export default class SessionLogExporter extends Service {
|
|
81
|
+
static inject: string[];
|
|
82
|
+
static Config: z<Config>;
|
|
83
|
+
private readonly compressionLevel;
|
|
84
|
+
constructor(ctx: Context, config: Config);
|
|
85
|
+
/**
|
|
86
|
+
* Prepare and expose one bounded ZIP byte stream.
|
|
87
|
+
* @param request - root identity and descendant policy.
|
|
88
|
+
* @param signal - producer lifetime; consumer cancellation is combined with it.
|
|
89
|
+
* @returns suggested filename and archive stream after root preflight succeeds.
|
|
90
|
+
*/
|
|
91
|
+
stream(request: SessionLogExportRequest, signal: AbortSignal): Promise<SessionLogExportStream>;
|
|
92
|
+
/**
|
|
93
|
+
* Stream one archive to a private sibling file and publish it atomically under
|
|
94
|
+
* the first available safe filename. Existing exports are never overwritten.
|
|
95
|
+
* The destination must be an existing absolute directory. Cancellation or a
|
|
96
|
+
* producer/write failure removes the partial file and publishes nothing.
|
|
97
|
+
* @param request - root identity and descendant policy.
|
|
98
|
+
* @param directory - existing absolute host directory chosen by the operator.
|
|
99
|
+
* @param signal - complete preparation, streaming, and publication lifetime.
|
|
100
|
+
* @returns the exact published path and filename.
|
|
101
|
+
*/
|
|
102
|
+
writeToDirectory(request: SessionLogExportRequest, directory: string, signal: AbortSignal): Promise<SessionLogExportFile>;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Package-owned invariant companion for host Session export. @module @lingxi-ai-cn/dsh-session-export/invariant */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export declare const name = "host-session-export-invariant";
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Register the package invariant companion.
|
|
9
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
10
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
11
|
+
*/
|
|
12
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
13
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side Session ZIP producer: streams one archive whose files are the
|
|
3
|
+
* sessions' stored artifact text verbatim plus every referenced media object.
|
|
4
|
+
* The root artifact sits under its original base name (`session.jsonl`); each
|
|
5
|
+
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
|
6
|
+
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
|
7
|
+
* so one archive never duplicates a shared image). No manifest is written —
|
|
8
|
+
* every file is byte-identical to the backend's durable artifact or attachment
|
|
9
|
+
* store and self-describing through its own header line or media type. Before
|
|
10
|
+
* each live session's artifact read, the SessionStore flush barrier makes the
|
|
11
|
+
* current in-memory log durable; cold sessions need no barrier. Request abort
|
|
12
|
+
* and response-consumer cancellation share one producer signal and terminate
|
|
13
|
+
* the active compressor.
|
|
14
|
+
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
|
15
|
+
* bytes are produced incrementally and the host never holds the whole archive
|
|
16
|
+
* in one buffer; production waits for consumer pull whenever the response queue
|
|
17
|
+
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
|
18
|
+
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
|
19
|
+
* @module @lingxi-ai-cn/dsh-session-export/zip
|
|
20
|
+
*/
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
22
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
23
|
+
import type { SessionQueryEngine } from '@deepseek-ai/dsh-session-query';
|
|
24
|
+
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session';
|
|
25
|
+
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence';
|
|
26
|
+
/** Valid fflate DEFLATE levels accepted by session-log export. */
|
|
27
|
+
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
28
|
+
/** Balanced default used when a deployment omits compression config. */
|
|
29
|
+
export declare const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel;
|
|
30
|
+
/** The services a session-log export needs (the live-session store is optional). */
|
|
31
|
+
export interface SessionLogExportDeps {
|
|
32
|
+
readonly sessionQuery: SessionQueryEngine | undefined;
|
|
33
|
+
readonly sessionPersistence: SessionPersistence | undefined;
|
|
34
|
+
readonly attachments: AttachmentStore | undefined;
|
|
35
|
+
readonly sessions: SessionStore | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The export services narrowed to the mounted ones streaming actually reads. */
|
|
38
|
+
export interface SessionLogExportReady {
|
|
39
|
+
readonly sessionQuery: SessionQueryEngine;
|
|
40
|
+
readonly sessionPersistence: SessionPersistence;
|
|
41
|
+
readonly attachments: AttachmentStore;
|
|
42
|
+
readonly sessions: SessionStore | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the persistence, session-query, and attachment services a log export needs.
|
|
46
|
+
* @param ctx - the composed host context.
|
|
47
|
+
* @returns the export services (absent when the deployment does not mount them).
|
|
48
|
+
*/
|
|
49
|
+
export declare function sessionLogExportDeps(ctx: Context): SessionLogExportDeps;
|
|
50
|
+
/**
|
|
51
|
+
* Flush one currently live session through the store's authoritative durability
|
|
52
|
+
* barrier immediately before its raw artifact is read. A cold or absent id has
|
|
53
|
+
* no in-memory work to flush.
|
|
54
|
+
* @param deps - export services, including the optional live-session store.
|
|
55
|
+
* @param id - the session whose artifact is about to be read.
|
|
56
|
+
* @param signal - optional cancellation observed around the flush barrier.
|
|
57
|
+
*/
|
|
58
|
+
export declare function flushLiveSessionLog(deps: Pick<SessionLogExportDeps, 'sessions'>, id: SessionId, signal?: AbortSignal): Promise<void>;
|
|
59
|
+
/** One exported file: a stored artifact text or one referenced media object. */
|
|
60
|
+
export type SessionLogZipEntry = {
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly content: string;
|
|
63
|
+
} | {
|
|
64
|
+
readonly path: string;
|
|
65
|
+
readonly data: Uint8Array;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The export archive filename for one root session.
|
|
69
|
+
* @param sessionId - the root session id (sanitized to one safe path segment).
|
|
70
|
+
* @returns the attachment filename for the session's export archive.
|
|
71
|
+
*/
|
|
72
|
+
export declare function sessionLogZipFilename(sessionId: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Yield the export entries in zip order: the preloaded root artifact first,
|
|
75
|
+
* then every subagent descendant in lineage order (each flushed when live,
|
|
76
|
+
* read from the persistence backend right before it is yielded, and dropped
|
|
77
|
+
* after the consumer moves on), then every distinct media object referenced by any of
|
|
78
|
+
* the included logs (read and verified from the attachment store, one archive
|
|
79
|
+
* entry per attachment id). The host holds at most one descendant's artifact
|
|
80
|
+
* text and one media object at a time beyond the root.
|
|
81
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
82
|
+
* @param root - the already-read root artifact (read by the caller so the
|
|
83
|
+
* missing-session path can answer cleanly before streaming starts).
|
|
84
|
+
* @param sessionId - the root session id.
|
|
85
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
86
|
+
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
|
87
|
+
* @returns the export entries in zip order.
|
|
88
|
+
*/
|
|
89
|
+
export declare function sessionLogZipEntries(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal): AsyncGenerator<SessionLogZipEntry>;
|
|
90
|
+
/**
|
|
91
|
+
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
|
92
|
+
* read and validated by the caller before this is called (missing root or
|
|
93
|
+
* missing services answer cleanly before any byte is produced); each entry is
|
|
94
|
+
* then encoded and deflated in bounded chunks as it is produced, so the
|
|
95
|
+
* archive bytes arrive incrementally. A descendant that fails to read errors
|
|
96
|
+
* the stream (fail-loud, never silent under-export).
|
|
97
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
98
|
+
* @param root - the already-read root artifact (first zip entry).
|
|
99
|
+
* @param sessionId - the root session id.
|
|
100
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
101
|
+
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
|
102
|
+
* @param signal - request cancellation combined with response-consumer cancellation.
|
|
103
|
+
* @returns the zip byte stream.
|
|
104
|
+
*/
|
|
105
|
+
export declare function streamSessionLogZip(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, compressionLevel: SessionLogCompressionLevel, signal: AbortSignal): ReadableStream<Uint8Array>;
|
|
106
|
+
//# sourceMappingURL=zip.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lingxi-ai-cn/dsh-session-export",
|
|
3
|
+
"description": "Host-owned streaming Session ZIP producer and atomic path writer",
|
|
4
|
+
"version": "0.1.0-rc.8",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Lingxi-AI-cn/dsh-tui-plugin.git",
|
|
11
|
+
"directory": "packages/host/session-export"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/invariant.js",
|
|
30
|
+
"lib/types/**/*.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"fflate": "^0.8.2"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
38
|
+
"@deepseek-ai/dsh-attachment": "0.1.0-rc.8",
|
|
39
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
|
|
40
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
|
|
41
|
+
"@deepseek-ai/dsh-session-persistence": "0.1.0-rc.8",
|
|
42
|
+
"@deepseek-ai/dsh-session-query": "0.1.0-rc.8",
|
|
43
|
+
"@deepseek-ai/schemastery": "3.18.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
47
|
+
"@deepseek-ai/dsh-attachment": "0.1.0-rc.8",
|
|
48
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
|
|
49
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
|
|
50
|
+
"@deepseek-ai/dsh-session-persistence": "0.1.0-rc.8",
|
|
51
|
+
"@deepseek-ai/dsh-session-query": "0.1.0-rc.8",
|
|
52
|
+
"@deepseek-ai/schemastery": "3.18.1"
|
|
53
|
+
}
|
|
54
|
+
}
|