fix(apiproxy): cancel attachment reads during export

Response-consumer cancellation already stopped lineage reads, persistence reads, and ZIP production, but the final attachment phase called readImage without the producer signal. A slow or stalled attachment backend could therefore keep working after the browser abandoned the download and prevent the producer from settling.\n\nExtend the attachment read seam with optional cancellation, forward it through the local backend into Node's filesystem read, and preserve the abort reason rather than wrapping it as a storage failure. The exporter now passes its combined request/consumer signal to every attachment read.\n\nCover both ownership boundaries: the local-store test proves filesystem forwarding and cancellation identity, while the assembled export test cancels a reader during a pending attachment provider call. Regenerate the Cordis API catalog and paired documentation so implementers can rely on the new contract.
This commit is contained in:
Tianyi Cui
2026-08-11 17:52:24 +08:00
parent 5e067fa7fe
commit c10d74ba95
19 changed files with 101 additions and 27 deletions

View File

@@ -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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
2026-08-10-web-session-log-export.md: 68b164578263efe0f0a879e4e4acbdf8a9f945c8
2026-08-10-web-session-log-export.zh.md: c3172bc3353073d50747485fbe0220e777a7c146
2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d
2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2

View File

@@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
## Decision
- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 09 (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 artifact 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 — every file is byte-identical to the durable artifact and self-describing through its own header line.
- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (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 and persistence reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (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. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor 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 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser.

View File

@@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
## 决策
- **导出是宿主侧的下载面,不是 RPC**`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 09默认 6使部署可以在 CPU延迟与归档大小之间取舍每个条目按有界分块边产出边压缩响应随生成分块写出宿主从不把整个归档放进单个缓冲区除预载的根外最多同时持有一条后代的工件文本。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致并通过自身 header 行自描述。
- **错误词汇是 HTTP 原生的**:服务缺失 → 500后端不提供每会话原始工件 → 501根会话缺失 → 404三者都在任何字节流出前判定后代缺少存储工件 → 流失败fail-loud绝不静默少导出。请求中止会保持取消语义而不会改写成 500请求取消与响应 Consumer 取消汇合到生产者 signal该 signal 会传到血缘持久化读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`host-only、无 wire 信封、不在 `IApiClient` 上)实现。
- **错误词汇是 HTTP 原生的**:服务缺失 → 500后端不提供每会话原始工件 → 501根会话缺失 → 404三者都在任何字节流出前判定后代缺少存储工件 → 流失败fail-loud绝不静默少导出。请求中止会保持取消语义而不会改写成 500请求取消与响应 Consumer 取消汇合到生产者 signal该 signal 会传到血缘持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`host-only、无 wire 信封、不在 `IApiClient` 上)实现。
- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。