DSH / Atlas
2026-08-10implementedfeaturearchived 2026-09-04

Web session-log export as a host-streamed ZIP download

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

The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around.

English

Problem

The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around.

Decision

  • The export is a host-only download, not an RPC: GET /api/session.export?sessionId=…&includeDescendants=true streams one ZIP attachment. Every Session file is canonical current JSONL produced from a persistence read handle: one v2 header row followed by one validated durable event per row. Backend-specific generation names, Zstandard framing, and historical packed rows stay behind the persistence seam; the archive always uses session.jsonl at the root and subagents/<id>/session.jsonl for descendants. Compression runs on the host with fflate's streaming Zip/ZipDeflate API at validated sessionExportCompressionLevel 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's log text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — each log is self-describing through its header and embedded v2 lineage marker, while referenced media keeps its content-addressed archive path.
  • Error vocabulary is HTTP-native: missing services → 500 and missing root session → 404, both decided before any byte streams; a descendant without a stored log or referenced media object errors the stream (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the /api trust fence before dispatching the exact GET/HEAD /api/session.export route registered by session-log-export.
  • The UI just downloads: browser consumers may issue a bodyless HEAD preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The session.log RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
  • The current Header and /export consumers are defined by the session-log export package contract.

Alternatives considered

  • session.log data RPC + client-side zip — shipped first, rejected with the user: the browser pulls the full raw JSON (≈10× the final zip size) and compresses on the main thread; for the 23 MB sessions in real use the host-side stream is strictly better. The RPC was deleted with the migration rather than left as a dead public surface.
  • Single JSONL with envelope lines for multiple sessions — rejected with the user: mixing sessions in one JSONL loses clean per-file boundaries; a ZIP keeps one canonical file per session.
  • jszip — heavier (~100 kB) and its dependency graph pulls readable-stream browser mappings; fflate is purpose-built and small.
  • Vendoring fflate's browser entry — the repo vendoring procedure targets cordis-scale pinned sources; a resolveId alias keeps the maintained dependency without shipping a copy (and host-side fflate needs no alias at all).

Consequences

  • Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative SessionStore.flush durability barrier; every exported file represents that validated durable logical log in the same current JSONL form, independent of backend framing. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is dsh-session-<sanitized-id>.zip and archive paths sanitize ids before they can shape entries.
  • The export needs no seam capability: each log is read through a persistence read handle and serialized here as canonical JSONL, so any mounted backend exports identically (export and pre-release trims records the removal of the earlier verbatim-artifact surface). Absence is decided by a stat preflight (absent session → 404). session-log-export registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response.
  • Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button.
  • Deferred: transcript.md and a report/feedback bundle remain future work; the stable per-session JSONL and manifest-free layout keep that extension cheap.

中文

问题

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

决策

  • 导出是宿主侧的下载面,不是 RPC:GET /api/session.export?sessionId=…&includeDescendants=true 流式返回一个 ZIP 附件。每个 Session 文件都是从持久化读句柄生成的规范当前 JSONL:一行 v2 header,随后每个已校验持久事件各占一行。后端专属 generation 名称、Zstandard framing 与历史 packed row 都留在持久化 seam 后面;archive 始终把 root 写为 session.jsonl,把 descendant 写为 subagents/<id>/session.jsonl。压缩在宿主侧使用 fflate 流式 Zip/ZipDeflate API 和已验证的 sessionExportCompressionLevel 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的 root 外,最多同时持有一条 descendant 的日志文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界 input push。不写 manifest——每条日志通过自己的 header 与嵌入式 v2 lineage marker 自描述,引用的 media 则保留其 content-addressed archive path。
  • 错误词汇是 HTTP 原生的:服务缺失 → 500,根会话缺失 → 404,两者都在任何字节流出前判定;descendant 缺少 stored log 或引用的 media object 会使 stream 失败(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 持久性屏障;每个导出文件都以相同的当前 JSONL 形式表示该经过校验的持久 logical log,不受后端 framing 影响。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 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 打包留待后续;稳定的逐会话 JSONL 与无 manifest 布局让该扩展保持廉价。