diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml new file mode 100644 index 0000000000..0c8a3f2781 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 427b6478ac44fb28030aa932630f276de7bb2edc +2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md new file mode 100644 index 0000000000..427b6478ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -0,0 +1,30 @@ +# Agent Note: Web session-log export as a host-streamed ZIP download + +Status: implemented + +English | [中文](2026-08-10-web-session-log-export.zh.md) + +## 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 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//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry 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), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). 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, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). 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 fetches the endpoint and saves the response; 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 no longer carries fflate (the earlier browser-entry-alias pitfall is moot). +- 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; a failure surfaces in a visible alert bar under the toolbar. + +## 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: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. +- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button. +- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md new file mode 100644 index 0000000000..63b9804a54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Web 会话日志导出——宿主流式 ZIP 下载 + +状态:implemented + +[English](2026-08-10-web-session-log-export.md) | 中文 + +## 问题 + +Trajectory 视图没有任何方式把调试工件交到人手里:原始会话日志存放在磁盘与宿主侧,客户端历史面只提供折叠后的投影(而非原始事件),而带子代理的会话横跨多个相互独立的会话日志。bug 报告需要整棵会话树的完整原始日志,并且形态要能在被转发后仍然可用。 + +## 决策 + +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 +- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 + +## 考虑过的替代方案 + +- **`session.log` 数据 RPC + 客户端打包**——先发布,后与用户共同否决:浏览器要拉取完整原始 JSON(约为最终 zip 的 10 倍)并在主线程压缩;对实际使用中 23 MB 级别的会话,宿主流式严格更优。迁移时把该 RPC 一并删除,而不是留作无消费者的公共接口。 +- **用信封行把多会话编码进单一 JSONL**——与用户共同否决:把多个会话混进一个 JSONL 会失去干净的按文件边界;ZIP 让每个会话保持一个规范文件。 +- **jszip**——更重(约 100 kB),依赖图还会拉入 readable-stream 的浏览器映射;fflate 专为此而生且体积小。 +- **将 fflate 浏览器入口 vendoring 进仓库**——仓库的 vendoring 流程面向 cordis 级别的固定源码;resolveId 别名在保持维护中的依赖的同时无需复制代码(宿主侧 fflate 根本不需要别名)。 + +## 后果 + +- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 +- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。 +- 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0ecae4b668..e0c71e9903 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -59,6 +59,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | diff --git a/apps/web/package.json b/apps/web/package.json index 828dffbe70..aed4488355 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "playwright": "^1.49.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "fflate": "^0.8.2" } } diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index b96a1fa393..c8b6455296 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' +import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await page.getByRole('button', { name: 'Export session log' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/) + // The real host streamed the ZIP; its root entry is the persisted log + // text verbatim (the assembled seam: real route, real persistence read). + const files = unzipSync(await readFile(await download.path())) + expect(Object.keys(files)).toEqual(['session.jsonl']) + const content = strFromU8(files['session.jsonl'] as Uint8Array) + expect(content.split('\n')[0]).toContain(SEED_ID) + expect(content).toContain('FIRST_DONE') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index a9b5dbb982..3476255bab 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,6 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls + - button "Export session log": Export - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a20a76a7ef..08e9adf7fc 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 6188fec1cfe65369e59c10b851629122c393af1e +config-catalog.md: 5fd3182b305d16d8c235736c5f50aa1fb3661ce0 config-catalog.zh.md: 1aefcaff17d272d4767a22a5ba72a7ab6dc0b914 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6188fec1cf..5fd3182b30 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1430,7 +1430,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:59`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3f5820ec3e..76c5f5725f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 859a449f63bd36177a9c666ac9d894cb495b224f -module-graph.zh.md: cedf087810cf309200368b43c6d53757426c58cd +module-graph.md: 44eef9fdd7a812d4d4d6973ba8366dd3ef86a713 +module-graph.zh.md: 5984c74cef191c1ae268f2cd8e408e896007404a diff --git a/docs/module-graph.md b/docs/module-graph.md index 859a449f63..44eef9fdd7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -902,6 +902,7 @@ flowchart TD pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_compact @@ -1421,7 +1422,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index cedf087810..5984c74cef 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -904,6 +904,7 @@ flowchart TD pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_compact @@ -1423,7 +1424,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index f81e040bda..1c442fb1a2 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -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 docs/subsystems/persistence.md -persistence.md: 7deaa9b30b5a6b1e3cbdcc38255b3974b5abf477 -persistence.zh.md: c5afcf67319da408b739d41b2b7ad3eb434ffbad +persistence.md: fd694161ed8ae4c364de5c22d8eb06f1b0a91aec +persistence.zh.md: b616b282204e946e18e90271d1eaeb2d4ed70fc3 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 7deaa9b30b..fd694161ed 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -122,6 +122,22 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionRawArtifact` — verbatim stored artifact text + +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default. + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## Preparation and restoration ownership `SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. @@ -241,6 +257,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -346,5 +377,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index c5afcf6731..b616b28220 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -122,6 +122,22 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## `SessionRawArtifact`——逐字存储工件文本 + +后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。 + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## 准备与恢复所有权 `SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 @@ -241,6 +257,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -346,5 +377,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f149e28984..08837db9c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,6 +2834,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, + // Satisfies the ApiProxy contract type only: the browser export button + // fetches GET /api/session.export directly (window.fetch), so this stub is + // never reached through the fixture's dispatch. + downloads: { + sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), + }, } const rpc: ClientConnectionRpc = { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 78082eb1f0..baba46ae81 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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/client/ui-trajectory/README.md -README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4 -README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0 +README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d +README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d3786b6460..e82b2cc9d4 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/.`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5eb1451b9a..a1ba62393c 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/.` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index a0c76575ae..49b100620b 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -49,6 +50,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -60,6 +62,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index ca7a96027a..5d2d4d8e31 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -156,8 +156,8 @@ type DetailTab = | 'tools' | 'overview' | 'rendered' + | 'raw' | 'source' - | 'origin' | 'input' | 'output' | 'schema' @@ -800,7 +800,7 @@ function RequestOptions({ ) } -function messageOriginLabel(source: unknown): string { +function messageSourceLabel(source: unknown): string { if (typeof source !== 'object' || source === null || Array.isArray(source)) { return 'Unknown' } @@ -823,16 +823,16 @@ function messageOriginLabel(source: unknown): string { return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}` } -function MessageOrigin({ record }: { record: TableRecord }) { +function MessageSource({ record }: { record: TableRecord }) { const source = record.cell.messageSource - if (source === undefined) return

Origin not recorded

+ if (source === undefined) return

Source not recorded

const data = typeof source === 'object' && source !== null ? source : { value: source } return ( ) @@ -897,17 +897,17 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { if (record.cell.kind === 'compacted') { return [ { id: 'overview', label: 'Summary' }, - { id: 'source', label: 'Raw Output' }, + { id: 'raw', label: 'Raw Output' }, ] } if (isMarkdownRecord(record)) { return [ { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, - { id: 'source', label: 'Source' }, + { id: 'raw', label: 'Raw' }, ...(record.cell.messageSource === undefined ? [] - : [{ id: 'origin', label: 'Origin' } as const]), + : [{ id: 'source', label: 'Source' } as const]), ] } return [ @@ -2855,14 +2855,14 @@ export function TrajectoryTable({ > {selected.cell.messageSource !== undefined && (
-
Origin
+
Source
+
@@ -111,8 +139,8 @@ export function TrajectoryToolbar({ { onSearchQueryChange(event.currentTarget.value) }} /> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 2e38aee078..99eb823ae6 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { AssistantBlock, AssistantMessageNode, ConversationSnapshot, SnapshotStore, @@ -71,6 +71,8 @@ export interface TrajectoryViewInjected { } loadOlder: () => Promise setActualDuration: (actualDuration: boolean) => void + /** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */ + exportLog: () => Promise } interface UsageLike { @@ -118,9 +120,9 @@ function addUsage( } export function TrajectoryView({ - useSession, useDuration, loadOlder, setActualDuration, - inspect, onInspectDone, -}: ConvViewProps & InjectFace) { + useSession, useDuration, loadOlder, setActualDuration, exportLog, + inspect, onInspectDone, t, +}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) @@ -128,6 +130,8 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [exporting, setExporting] = useState(false) + const [exportError, setExportError] = useState(null) const [searchIndex] = useState(() => new TrajectorySearchIndex()) const [searchIndexRevision, setSearchIndexRevision] = useState(0) const searchIndexTimer = useRef | null>(null) @@ -443,6 +447,19 @@ export function TrajectoryView({ return loadOlder() }, [loadOlder]) + const onExport = useCallback(() => { + if (exporting) return + setExporting(true) + setExportError(null) + void exportLog().then( + () => { setExporting(false) }, + (error: unknown) => { + setExportError(error instanceof Error ? error.message : String(error)) + setExporting(false) + }, + ) + }, [exportLog, exporting]) + return (
+ {exportError !== null && ( +
+ {exportError} +
+ )} { URL.revokeObjectURL(url) }, 0) +} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index a8a41f5183..8337e060c9 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -4,20 +4,24 @@ */ import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' -import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +import { downloadBlob, sessionLogZipFilename } from './export-log.ts' +import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts' import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts' import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts' import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' +import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -/** Required services: the conversation slot, registries, and ordinary Session paging. */ -export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions'] +/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */ +export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -25,6 +29,11 @@ export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sess * @param ctx - client root context. */ export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration. + const t = ctx.locale.bind(NS) const duration = createTrajectoryDurationStore() registerTrajectoryMessageDefinitions(ctx) registerTrajectoryRequestHeaderDefinition(ctx) @@ -36,7 +45,8 @@ export function apply(ctx: Context): void { name: 'conversation.view', id: 'trajectory', order: 10, - label: 'Trajectory', + locale: NS, + label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) { @@ -50,6 +60,23 @@ export function apply(ctx: Context): void { return session.getSnapshot().views.get('trajectory') !== before }, setActualDuration: (value) => { duration.set(value) }, + exportLog: async () => { + // The host streams the ZIP (root + descendant artifacts verbatim) + // from GET /api/session.export; the browser downloads the response. + // A null origin (no-location Node contexts) falls back like the + // carrier's resolveBase so the URL stays valid. + const loc = (globalThis as { location?: { origin?: string } }).location + const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' + const url = new URL('/api/session.export', origin) + url.searchParams.set('sessionId', sessionId) + url.searchParams.set('includeDescendants', 'true') + const response = await fetch(url) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) + } + downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) + }, } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts new file mode 100644 index 0000000000..b226974160 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -0,0 +1,76 @@ +/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'trajectory' + +/** The trajectory dictionary key set (the source of truth for both locales). */ +export type TrajectoryKey = + | 'view.trajectory' + | 'toolbar.aria' + | 'toolbar.duration' + | 'toolbar.useActualDuration' + | 'toolbar.useEqualWidth' + | 'toolbar.actualTime' + | 'toolbar.turns' + | 'toolbar.expandTurns' + | 'toolbar.collapseTurns' + | 'toolbar.calls' + | 'toolbar.expandCalls' + | 'toolbar.collapseCalls' + | 'toolbar.export' + | 'toolbar.exportAria' + | 'toolbar.exporting' + | 'toolbar.exportTitle' + | 'toolbar.search' + | 'toolbar.searchPlaceholder' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The trajectory view tab label and toolbar strings. */ + 'trajectory': TrajectoryKey + } +} + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh: Record = { + 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', +} + +/** English dictionary. */ +export const en: Record = { + 'view.trajectory': 'Trajectory', + 'toolbar.aria': 'Trajectory toolbar', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': 'Actual time', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': 'Search trajectory', + 'toolbar.searchPlaceholder': 'Search', +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..842486ea93 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,6 +13,18 @@ background: var(--dsw-alias-bg-layer-1); } +.exportError { + box-sizing: border-box; + flex: none; + width: 100%; + padding: 4px 10px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary)); + background: var(--dsw-alias-bg-layer-2); + font: var(--dsw-font-xxs-12); + overflow-wrap: anywhere; +} + .ledger { position: relative; z-index: 0; diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 9590f28b5e..b7a3d987e0 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -64,7 +64,7 @@ describe('tsdown client artifact', () => { expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') expect(surface.inject).toEqual([ - 'slots', 'conversationEvents', 'conversationViews', 'sessions', + 'slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale', ]) }) @@ -80,8 +80,13 @@ describe('tsdown client artifact', () => { children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) // Paging is session-owned; this registration-only probe never renders the - // entry, so the binding stays deliberately empty. + // entry, so the binding stays deliberately empty. The locale plugin backs + // the locale-aware view tab label (its settings scope needs a connection + // handle). ctx.provide('sessions', { binding: () => undefined }) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + const locale = await import('@deepseek-ai/dsh-client-locale/client') + ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() const events = ctx.get('conversationEvents') as ConversationEventRegistry diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts new file mode 100644 index 0000000000..ba7f739573 --- /dev/null +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +/** + * Session-log export filename derivation. The archive itself is produced and + * streamed by the host (GET /api/session.export); this package only derives + * the download filename and triggers the browser save. + */ + +import { describe, expect, it } from 'vitest' +import { sessionLogZipFilename } from '../src/client/export-log.ts' + +describe('sessionLogZipFilename', () => { + it('keeps safe session ids verbatim', () => { + expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') + }) + + it('neutralizes unsafe id characters that could shape the filename', () => { + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') + expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') + }) + + it('strips dots so a dot-only id cannot shape a dot segment', () => { + expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') + }) +}) diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx new file mode 100644 index 0000000000..e2b761143a --- /dev/null +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' + +/** Test translator pinned to the Simplified Chinese dictionary. */ +const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function baseProps(overrides: Partial = {}): TrajectoryToolbarProps { + return { + actualDuration: false, + onActualDurationChange: vi.fn(), + actualTime: false, + onActualTimeChange: vi.fn(), + allTurnsCollapsed: false, + onToggleAllTurns: vi.fn(), + allAssistantsCollapsed: false, + onToggleAllAssistants: vi.fn(), + searchQuery: '', + onSearchQueryChange: vi.fn(), + exporting: false, + onExport: vi.fn(), + exportError: null, + t: zhT, + ...overrides, + } +} + +describe('TrajectoryToolbar export', () => { + it('renders the export button and dispatches the export callback on click', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Export session log' }) + fireEvent.click(button) + expect(onExport).toHaveBeenCalledTimes(1) + }) + + it('disables the button while an export is in flight and blocks dispatch', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Export session log' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(onExport).not.toHaveBeenCalled() + }) + + it('surfaces an export failure as the button title', () => { + render() + const button = screen.getByRole('button', { name: 'Export session log' }) + expect(button.title).toBe('Export failed: internal boom') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5235235d90..d95d70168b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -29,6 +29,9 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -132,6 +135,12 @@ function standaloneDuration(): Pick< } } +function standaloneExport( + onExport: () => Promise = vi.fn(() => Promise.resolve()), +): Pick, 'exportLog'> { + return { exportLog: onExport } +} + function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore(historySnapshot(nodes)) return { store, useSession: bindSnapshotSelector(store) } @@ -153,14 +162,18 @@ function emptyWorkspaces() { } /** Standalone view props: the session-scope standard kit the outlet would bake. */ -function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { +function standaloneProps( + nodes: ConversationSnapshot['nodes'], +): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, - } as unknown as ConvViewProps + // The locale seat the outlet would inject for the declared namespace. + t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, + } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ @@ -188,6 +201,10 @@ async function bench(snapshot = historySnapshot(NODES)) { const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) + // The locale plugin backs the locale-aware view tab label ('locale' in + // inject); its settings scope needs a connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadOlder, sessionStore } @@ -232,7 +249,9 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES return { loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, + exportLog: trajectory.exportLog, useDuration: bindSnapshotSelector(trajectory.hooks.duration), + t: (key: TrajectoryKey) => zh[key], } })() : injected @@ -352,7 +371,7 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) @@ -365,6 +384,17 @@ describe('tab switching in ConversationRoot', () => { expect(b.loadOlder).not.toHaveBeenCalled() }) + it('labels the trajectory tab in the active locale', async () => { + const b = await bench() + const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label + expect(labelOf()).toBe('Trajectory') + const locale = b.ctx.get('locale') as { setLocale(id: string): void } + locale.setLocale('zh') + expect(labelOf()).toBe('轨迹') + locale.setLocale('en') + expect(labelOf()).toBe('Trajectory') + }) + it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() mount(b.slots) @@ -553,7 +583,7 @@ describe('tab switching in ConversationRoot', () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByRole('button', { name: 'Collapse turns', @@ -1100,13 +1130,62 @@ describe('timeline projection', () => { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), ...standaloneDuration(), + ...standaloneExport(), }, )) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) +describe('session log export', () => { + afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(URL, 'createObjectURL') + Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') + }) + + it('downloads the host-streamed ZIP with descendants on click', async () => { + // exportLog always fetches a URL instance, so the mock's shape stays narrow. + const fetchMock = vi.fn(async (input: URL) => { + expect(input.pathname).toBe('/api/session.export') + expect(input.searchParams.get('sessionId')).toBe(SID) + expect(input.searchParams.get('includeDescendants')).toBe('true') + return new Response('zip-bytes') + }) + vi.stubGlobal('fetch', fetchMock) + const createObjectURL = vi.fn(() => 'blob:export') + URL.createObjectURL = createObjectURL + const clickAnchor = vi.fn() + HTMLAnchorElement.prototype.click = clickAnchor + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledOnce() + }) + // The blob download lands a few microtasks after the fetch settles. + await vi.waitFor(() => { + expect(createObjectURL).toHaveBeenCalled() + }) + expect(clickAnchor).toHaveBeenCalled() + }) + + it('surfaces the download failure in the visible alert bar', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 }))) + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) + await vi.waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + expect(alert!.textContent).toContain('HTTP 404') + }) + }) +}) + describe('TrajectoryView state', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() @@ -1117,6 +1196,7 @@ describe('TrajectoryView state', () => { const first = render( { firstDuration.set(value) }} />, @@ -1132,6 +1212,7 @@ describe('TrajectoryView state', () => { render( { restoredDuration.set(value) }} />, @@ -1140,6 +1221,8 @@ describe('TrajectoryView state', () => { .toBe('true') }) + + it('keeps ledger and timeline selection on the same event after prepend', () => { const older = { kind: 'user', seq: 1, time: 1_000, @@ -1154,6 +1237,7 @@ describe('TrajectoryView state', () => { Promise.resolve(false))} />, diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 5feffced67..29ff1c3357 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-conversation" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index d89a5f518b..72568f241d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 2a27ab9f2cfcd4f12c32b815583b13f439b3eaac -README.zh.md: ba49ccff82bab1e677bad1495e48b8503564ae1d +README.md: 5fe19af8069766c56f8926ccef88dc1d9fb3c950 +README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 2a27ab9f2c..5fe19af806 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,6 +28,8 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. + Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index ba49ccff82..bdb26a6383 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,6 +28,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 + 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 7f6d6e53aa..1145ee492b 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", + "fflate": "^0.8.2", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6b71a6ce87..29271b6921 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -42,6 +42,13 @@ import type { QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' +import { + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, + type SessionLogExportReady, +} from './session-export.ts' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, @@ -3422,6 +3429,46 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + downloads: { + async sessionLog(request, signal) { + // Clean error path first: missing services answer 500 and a missing + // root artifact 404 before any zip byte is produced. The root content + // read here is reused as the first zip entry, so nothing is read twice. + 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 }, + ) + } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + attachments: deps.attachments, + } + let root: SessionRawArtifact | undefined + try { + root = await deps.sessionPersistence.readRaw(request.sessionId, signal) + } catch { + // Backend read failure: answer 500 without echoing the error, which + // may carry absolute host paths into the browser error bar. + return new Response('session log export failed to read the stored artifact', { status: 500 }) + } + if (root === undefined) { + return new Response('session not found', { status: 404 }) + } + return new Response( + streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, + }, + }, + ) + }, + }, + respond(message: ClientResponse): Promise { // Route by the echoed rpcId (the wire correlation): approvals first, // then questions — the two registries share one id space of UUIDs. diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts new file mode 100644 index 0000000000..8a5b371e7f --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -0,0 +1,26 @@ +/** + * downloads domain zod schemas. The GET download surface has no wire + * envelope: the request arrives as query parameters (all strings), so its + * request schema parses the raw query-parameter object into the method's + * exact request shape. SessionId brand cast point: sessionIdSchema, and only + * there (hosted in sessions.schema like every other cast). + */ + +import { z } from 'zod' +import type { DownloadsApi } from './downloads.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +/** + * session.export query params → the sessionLog request. `includeDescendants` + * accepts exactly `true`/`false`/absent; any other value is rejected (400) so + * a misspelled flag cannot silently under-export. + */ +export const sessionLogQuerySchema = z + .object({ + sessionId: sessionIdSchema, + includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), + }) + .transform(query => ({ + sessionId: query.sessionId, + ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), + })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts new file mode 100644 index 0000000000..d0e6138b6a --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -0,0 +1,25 @@ +/** + * downloads domain contract: host-only download surfaces — the GET-download + * channel family, the mirror of the SSE-stream `events` domain. No wire + * envelope: the carrier's GET routes answer these directly, and the browser + * `IApiClient` never exposes them. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ +export interface DownloadsApi { + /** + * Stream one session-log ZIP — the root artifact verbatim plus each subagent + * descendant's — as an attachment response. The carrier's GET route answers + * this directly; the browser never calls it. + * @param request - the root session id and whether to include descendants. + * @param signal - cancellation for the underlying reads. + * @returns the ZIP attachment response; missing services answer 500 and a + * missing root session 404 before any byte is produced. + */ + sessionLog( + request: { sessionId: SessionId; includeDescendants?: boolean }, + signal: AbortSignal, + ): Promise +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index f49dfc9699..3247886bfe 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -16,6 +16,7 @@ import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' +import type { DownloadsApi } from './downloads.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -32,6 +33,8 @@ export interface ApiProxy { settings: SettingsApi credentials: CredentialsApi llm: LlmApi + /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ + downloads: DownloadsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -39,9 +42,8 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock, - SessionSearchItem, - SessionsApi, SessionSummary, + ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, + SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { @@ -58,6 +60,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' +export type { DownloadsApi } from './downloads.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f62a3584e7..1e902f059e 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { z } from 'zod' import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' @@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname + // No-envelope GET channel surface (SSE streams + host-only download): + // physical routes that answer directly, without a wire envelope. if (path === '/api/events.mux' && req.method === 'GET') { return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } if (path === '/api/events.host' && req.method === 'GET') { return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } + if (path === '/api/session.export' && req.method === 'GET') { + // Query params are a different boundary from the POST envelope, but + // the request still casts its brands only through the domain schema. + const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) + if (!parsed.success) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + return api.downloads.sessionLog(parsed.data, req.signal) + } if (req.method !== 'POST' || !path.startsWith('/api/')) { return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 9290005a52..6bb062dcad 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] + readonly downloads: ApiProxy['downloads'] readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { @@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.credentials = api.credentials this.llm = api.llm this.events = api.events + this.downloads = api.downloads // createApiProxy returns closures (no `this` capture), so the bind is // behavior-neutral. this.respond = api.respond.bind(api) diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts new file mode 100644 index 0000000000..73026be20a --- /dev/null +++ b/packages/host/apiproxy/src/session-export.ts @@ -0,0 +1,357 @@ +/** + * 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//`; each image referenced + * by any included log under `media/.` (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. + * 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 yields to the consumer whenever the response queue + * fills past its high-water mark, so a slow consumer bounds the accumulation + * instead of piling up the whole archive (fflate's callback is synchronous — + * this drain point is the only backpressure available). + * @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, SessionQueryService } from '@deepseek-ai/dsh-session-query' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** The services a session-log export needs (absent → the export is unavailable). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryService | undefined + readonly sessionPersistence: SessionPersistence | undefined + readonly attachments: AttachmentStore | undefined +} + +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryService + readonly sessionPersistence: SessionPersistence + readonly attachments: AttachmentStore +} + +/** + * 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'), + } +} + +/** 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 = { + '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): 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): 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 { + const refs = new Map() + 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 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 for read work. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + const media = new Map() + 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]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + const raw = await deps.sessionPersistence.readRaw(id) + 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) + yield* collect(lineage.descendants) + } + for (const ref of media.values()) { + signal?.throwIfAborted() + const stored = await deps.attachments.readImage(ref) + 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 + +/** + * Push one media object's bytes into a deflate stream in bounded chunks, + * yielding to a slow consumer between chunks like the artifact path does. + * @param deflate - the zip entry's deflate stream. + * @param data - the stored image bytes. + * @param signal - optional cancellation; throws when aborted. + */ +async function pushBinaryChunks( + deflate: ZipDeflate, + data: Uint8Array, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + 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 + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } 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 signal - optional cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + 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 + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } 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 signal - optional cancellation for read work. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): ReadableStream { + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; pushArtifactChunks + // yields between chunks once the queue is over-full, bounding the + // accumulation to the queue high-water mark plus one push. + const zip = 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() + }) + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { + const deflate = new ZipDeflate(entry.path, { level: 6 }) + zip.add(deflate) + if ('content' in entry) { + await pushArtifactChunks(deflate, entry.content, controller, signal) + } else { + await pushBinaryChunks(deflate, entry.data, controller, signal) + } + } + zip.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 */ + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + }) +} diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2b9249b7f0..9365006de4 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -133,6 +133,7 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 63c40414d8..ed286334a8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + downloads: { + async sessionLog() { + return new Response('stub', { status: 404 }) + }, + }, } } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts new file mode 100644 index 0000000000..923e70b380 --- /dev/null +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -0,0 +1,377 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { unzipSync, strFromU8 } from 'fflate' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +/** One durable image object served by the fake attachment store. */ +function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { + return { + ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, + data: new Uint8Array([1, 2, 3, 4]), + } +} + +/** A user/message event line carrying one image reference. */ +function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { + return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { + query?: boolean + persistence?: boolean | 'throw' + attachments?: boolean | ((ref: ImageAttachmentRef) => Promise>) + } = {}, +) { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const query = services.query ?? true + const persistence = services.persistence ?? true + if (query) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + }), + } as never) + } + if (persistence) { + ctx.provide('sessionPersistence', { + readRaw: async (id: SessionId) => { + if (persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }, + } as never) + } + if (services.attachments !== false) { + const readImage = typeof services.attachments === 'function' + ? services.attachments + : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) + ctx.provide('attachments', { + imageLimits: {} as never, + validateImage: async () => {}, + saveImage: async () => { throw new Error('export never saves images') }, + readImage, + } as never) + } + return createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to read the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('includes media objects referenced by the root log under media/.', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('img-1'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) + expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) + }) + + it('collects media referenced from nested tool results', async () => { + const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + nested, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) + }) + + it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { + const block = (id: string, mediaType: string) => + `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` + const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` + const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` + const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + wrapped, + inserted, + chunk, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'media/chunk-1.png', + 'media/inserted-1.gif', + 'media/wrapped-1.jpg', + 'session.jsonl', + ]) + }) + + it('deduplicates one media object referenced by several included logs', async () => { + const line = imageEventLine('shared-img') + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + line, + ].join('\n') + '\n') + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + line, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) + expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) + }) + + it('includes descendant media only when descendants are requested', async () => { + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + imageEventLine('child-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) + const without = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) + const withDescendants = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ + 'media/child-img.png', + 'session.jsonl', + 'subagents/child-a/session.jsonl', + ]) + }) + + it('fails the whole export when a referenced image cannot be read', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('gone-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async () => { throw new Error('attachment bytes missing') }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') + }) + + it('answers 500 when the deployment mounts no attachments service', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('attachments') + }) +}) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 732ab3fa00..ac636e9f35 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -718,6 +718,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, + { + signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + }, { signature: 'abstract create(meta: SessionHeader): Promise', jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', @@ -2849,6 +2853,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionRawArtifact', + declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 6411a077cb..42a3c431ce 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -18,7 +18,8 @@ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { @@ -233,6 +234,73 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** + * Read a session's stored artifact text verbatim: the durable file bytes + * decoded from this backend's physical encoding (complete zstd frames + * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the + * backend wrote — never a reconstruction from parsed events — so packed- + * chunk rows, key order, and line breaks survive byte-for-byte. A torn + * final frame is omitted, matching the committed-prefix semantics of every + * other read. + * @param id - the persisted session to read. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the raw artifact text plus the header parsed from its own first + * line, or `undefined` when the session has no stored artifact. + */ + override async readRaw(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + const { buffer } = await this.readStableFile(path, signal) + let content: string + if (this.compression === 'zstd') { + const { frames } = scanZstdFrames(buffer) + if (frames.length === 0) return undefined + const decoder = createZstdFrameDecoder() + const plaintexts: Buffer[] = [] + // The decoder yields views into a reused buffer; copy each frame's + // plaintext immediately so a later concat cannot read overwritten memory. + for (const plaintext of decoder.decode(buffer, frames)) { + signal?.throwIfAborted() + plaintexts.push(Buffer.from(plaintext)) + } + content = Buffer.concat(plaintexts).toString('utf8') + } else { + content = buffer.toString('utf8') + } + const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) + if (meta === undefined || meta.id !== id) { + throw new Error(`corrupt session log: invalid header line in "${path}"`) + } + // The logical artifact name is `session.jsonl` regardless of the physical + // encoding suffix (`.jsonl.zstd` marks compression only). + return { meta, filename: 'session.jsonl', content } + } + + /** + * Read a file's bytes under a revision-stable loop: a writer appending + * between stat and readFile would yield a torn physical file, so retry + * while the stat revision changes. + * @param path - the artifact file to read. + * @param signal - optional cancellation for the stat/read work. + * @returns the stable bytes and the revision that matched both stats. + */ + private async readStableFile( + path: string, + signal?: AbortSignal, + ): Promise<{ buffer: Buffer; revision: PersistenceRevision }> { + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + const buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) return { buffer, revision: after } + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -242,19 +310,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi expectedId?: SessionId, signal?: AbortSignal, ): Promise> { - let buffer: Buffer - let revision: PersistenceRevision - for (;;) { - signal?.throwIfAborted() - const before = fileRevision(await stat(path, { bigint: true })) - buffer = await readFile(path, { signal }) - signal?.throwIfAborted() - const after = fileRevision(await stat(path, { bigint: true })) - if (before === after) { - revision = after - break - } - } + const { buffer, revision } = await this.readStableFile(path, signal) let prefix: Omit, 'revision'> try { if (this.compression === 'zstd') { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 35b3829ed3..e980006e07 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -287,6 +287,46 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { + const m = meta('raw-read', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(m.id) + // Byte-identical to the physical file — never a reconstruction. + expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) + expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for an absent session', async () => { + const m = meta('raw-missing', '/work') + expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + }) + + it('readRaw rejects a corrupt header line instead of exporting it', async () => { + const m = meta('raw-corrupt', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') + await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + }) + + it('readRaw retries when the file revision changes during the read', async () => { + const m = meta('raw-revision-race', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + statRace.path = rawLogPath(root, '/work', m.id) + + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + // Two stat calls per iteration; the mocked revision change forces a retry. + expect(statRace.reads).toBe(4) + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 569c15cdd1..49d1182b44 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -356,6 +356,39 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) }) + it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-read-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const raw = await ctx.sessionPersistence.readRaw(header.id) + expect(raw).toBeDefined() + // The logical name drops the physical encoding suffix. + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(header.id) + expect(raw!.content).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(e => JSON.stringify(e)), + '', + ].join('\n')) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + // Overwrite the physical artifact with a short buffer: frame scanning + // answers zero frames before any magic check, so readRaw reports no artifact. + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 97ae8438f1..aa01f68f7a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -30,6 +30,16 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A backend's own raw artifact text for one session, verbatim. */ +export interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} + // The backend-agnostic write-path orchestration first-party backends compose. export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, @@ -85,6 +95,26 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ + readRaw(_id: SessionId, signal?: AbortSignal): Promise { + if (signal?.aborted === true) { + return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')) + } + return Promise.resolve(undefined) + } + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index b9f7b8672b..a09516df29 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -246,6 +246,24 @@ runPersistenceContract('memory', async () => { } }) +describe('the inherited readRaw default', () => { + it('answers undefined and honors an aborted signal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), + ).rejects.toThrow() + // A non-Error abort reason falls back to a wrapped Error rejection. + const controller = new AbortController() + controller.abort('boom') + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), controller.signal), + ).rejects.toThrow('aborted') + }) +}) + // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are // atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a787f4c68e..eb6dd1b02a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,6 +380,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + fflate: + specifier: ^0.8.2 + version: 0.8.3 playwright: specifier: ^1.49.0 version: 1.61.1 @@ -2853,6 +2856,9 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis @@ -4486,6 +4492,9 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace + fflate: + specifier: ^0.8.2 + version: 0.8.3 '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -11709,6 +11718,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -17001,6 +17013,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a32117bab8..8fe9de649c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -322,6 +322,7 @@ export const LINK_MAP: Readonly> = { SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', + SessionRawArtifact: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3c6ef4e192..11de45ad78 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -440,6 +440,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionRawArtifact", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/session-query.md", "symbol": "SessionEventSurface",