deepseek-harness/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
Turtle bec6805d6a refactor(session-persistence)!: handle-based seam with a lifecycle-owned write path
The persistence seam is now create/open/stat/list returning per-session
SessionHandles (read/append/flush/close); every log read and write flows
through the owning handle. The seam package exports only the service and
handle contracts, consumer-visible errors, and pure durable-data
validation helpers; each backend owns its complete storage runtime, and
the shared contract suites pin equivalent observable behavior. The
backend routes published sessions' live events by id into the active
write handle; agent-loop only acquires, seeds, and closes the handle.
Resume appends interruptedTurnClosers through its write handle;
session-query owns the revision-keyed cold cache. Legacy-only surfaces
are removed in the same swap: locate/readRaw/supportsRawArtifacts, the
legacy event-shape read migration, zstd torn-frame salvage,
DSH_SESSION_JSONL, and hook transcript_path population; a torn final
zstd frame is discarded whole; the session-list cold blank probe returns
on stat metadata (eventCount derived from the last physical row,
sizeBytes). The WebUI ZIP export serializes the logical log from a read
handle, so both backends export identically.

Refs #3245
2026-09-01 23:19:02 +08:00

5 KiB
Raw Blame History

Agent Note: Web 会话日志导出——宿主流式 ZIP 下载

状态:implemented

English | 中文

问题

Trajectory 视图没有任何方式把调试工件交到人手里:原始会话日志存放在磁盘与宿主侧,客户端历史面只提供折叠后的投影(而非原始事件),而带子代理的会话横跨多个相互独立的会话日志。bug 报告需要整棵会话树的完整原始日志,并且形态要能在被转发后仍然可用。

决策

  • 导出是宿主侧的下载面,不是 RPC:GET /api/session.export?sessionId=…&includeDescendants=true 流式返回一个 ZIP 附件。每个文件都是会话存储工件的逐字原文:持久化服务上的 export(id) 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 session.jsonl,子代理为 subagents/<id>/session.jsonl)。压缩在宿主侧使用 fflate 流式 Zip/ZipDeflate API 和已验证的 sessionExportCompressionLevel 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
  • 错误词汇是 HTTP 原生的:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 session-log-export 注册的精确 GET/HEAD /api/session.export 路由前应用 /api 信任围栏。
  • UI 只负责下载:浏览器 Consumer 可以先发出不读取 body 的 HEAD 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 session.log RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
  • 当前 Header 与 /export Consumer 由 Session 日志导出包约定定义。

考虑过的替代方案

  • session.log 数据 RPC + 客户端打包——先发布,后与用户共同否决:浏览器要拉取完整原始 JSON(约为最终 zip 的 10 倍)并在主线程压缩;对实际使用中 23 MB 级别的会话,宿主流式严格更优。迁移时把该 RPC 一并删除,而不是留作无消费者的公共接口。
  • 用信封行把多会话编码进单一 JSONL——与用户共同否决:把多个会话混进一个 JSONL 会失去干净的按文件边界;ZIP 让每个会话保持一个规范文件。
  • jszip——更重(约 100 kB),依赖图还会拉入 readable-stream 的浏览器映射;fflate 专为此而生且体积小。
  • 将 fflate 浏览器入口 vendoring 进仓库——仓库的 vendoring 流程面向 cordis 级别的固定源码;resolveId 别名在保持维护中的依赖的同时无需复制代码(宿主侧 fflate 根本不需要别名)。

后果

  • 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 SessionStore.flush 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 dsh-session-<sanitized-id>.zip,归档路径在塑造条目前会先净化会话 id。
  • 导出不需要 seam 能力:每份日志经由持久化读句柄读取,并在此处序列化为规范 JSONL,因此任何挂载的后端导出完全一致(早先逐字工件表面的移除由导出与预发布裁剪记录)。缺失由一次 stat 预检判定(会话缺失 → 404)。session-log-export 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。
  • fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。
  • 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。