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 index e24e2894a5..c4740a54c4 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d -2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2 +2026-08-10-web-session-log-export.md: 24703bd5c98a91bf8708ae243ef7a44df4afb8f1 +2026-08-10-web-session-log-export.zh.md: 36e008586ad8e93be9a6e6d590d0e21388d99913 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 index 8fa62b877d..24703bd5c9 100644 --- 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 @@ -12,8 +12,8 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw - **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 at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. -- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. -- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. +- **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. +- The current Header and `/export` consumers are defined by the [Web export command and dialog decision](2026-08-11-web-export-command-and-dialog.md). ## Alternatives considered 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 index 3040dda992..36e008586a 100644 --- 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 @@ -12,8 +12,8 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 -- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 -- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 +- **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 +- 当前 Header 与 `/export` Consumer 由 [Web 导出命令与弹窗决策](2026-08-11-web-export-command-and-dialog.md)定义。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml index 5a00974ecb..5f491197b0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.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 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md -2026-08-11-web-export-command-and-dialog.md: f08a47faab770f59f531076bbb20d3a2e43563ab -2026-08-11-web-export-command-and-dialog.zh.md: 88d5995ad4072eb1dd254dda209fafc6f1b349f6 +2026-08-11-web-export-command-and-dialog.md: 385fc05f42af329e59d0989d5f4b2145a637db11 +2026-08-11-web-export-command-and-dialog.zh.md: a86c003211a83c90a264ab5f17c4357c8da3ccb3 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md index f08a47faab..385fc05f42 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md @@ -10,13 +10,13 @@ Session export needs a stable Session-level visible action and an equivalent sla ## Decision -`@deepseek-ai/dsh-session-export` registers a Web-only `/export` human command and provides the browser `ctx.sessionExport` controller. The command records an ordinary `command/run` and `command/done`; after `command.execute` returns a successful result, `dsh-client-ui-command` emits a local acknowledgment that asks this browser's controller to download ApiProxy's existing `GET /api/session.export` ZIP. Other clients render the broadcast command nodes without repeating the browser side effect. The 111×32 `Session log` capsule in the Session Header calls that controller directly. Both paths therefore use the same Host endpoint, browser save operation, in-flight state, error handling, and Modal. +`@deepseek-ai/dsh-session-export` registers a Web-only `/export` human command and provides the browser `ctx.sessionExport` controller. The command records an ordinary `command/run` and `command/done`; after `command.execute` returns a successful result, `dsh-client-ui-command` emits a local acknowledgment that asks this browser's controller to download ApiProxy's existing `GET /api/session.export` ZIP. Other clients render the broadcast command nodes without repeating the browser side effect. The 111×32 `Session log` capsule in the Session Header calls that controller directly. Both paths use a `HEAD` preflight for preparation errors, then hand the GET URL to the browser download manager so JavaScript never buffers the ZIP; they share the same in-flight state and Modal. -The Header contribution occupies the right-aligned `conversation.session.header.utilities` list and renders the `Session log` text capsule with its trailing download icon plus the shared Modal. The title-adjacent `conversation.session.header.actions` list continues to own mode, Subagent, and Task entries, so mounting Session export does not reorder or move them. The export contribution does not observe Session history. A per-Session controller collapses concurrent gestures, aborts active fetches when its plugin disposes, ignores late requests after disposal, and preserves a user's closed state when the request later completes. +The Header contribution occupies the right-aligned `conversation.session.header.utilities` list and renders the `Session log` text capsule with its trailing download icon plus the shared Modal. The title-adjacent `conversation.session.header.actions` list continues to own mode, Subagent, and Task entries, so mounting Session export does not reorder or move them. The export contribution does not observe Session history. A per-Session controller collapses concurrent gestures, aborts active preflights when its plugin disposes, ignores late requests after disposal, and preserves a user's closed state when the request later completes. The ZIP endpoint and persistence `readRaw` capability remain owned by `dsh-host-apiproxy` and the persistence package. The endpoint flushes a live root Session before reading its artifact, so the local acknowledgment cannot race ahead of durable command lifecycle rows. This package does not serialize Session events, write Host files, deliver Host paths, or implement SQLite fallback. -The package compiles its Host command and invariant through `tsconfig.host.json`, while `tsconfig.client.json` owns the browser controller, Header action, Modal, and their face-named tests. The repository Host and Client aggregates reference only the matching project, so their incompatible Cordis `Context` merges never enter one TypeScript program. +The package is an ordinary Client aggregate project. Its single `tsconfig.json` compiles the Node loader entries and browser contribution together; Host-side tests still exercise the command and invariant through their source entries. ## Alternatives considered @@ -28,4 +28,4 @@ The package compiles its Host command and invariant through `tsconfig.host.json` ## Consequences -The Header action and `/export` download the same ZIP and show the same feedback. An executed command remains visible in the durable transcript without creating a model turn. Deployments whose persistence backend has no raw per-Session artifact receive the endpoint's existing failure; SQLite support remains separate work. Command availability before a Session's first turn is separate work. +The Header action and `/export` download the same ZIP and show the same feedback. An executed command remains visible in the durable transcript without creating a model turn. The preflight reports failures found before streaming starts; failures while the browser consumes the GET remain browser-download failures. Deployments whose persistence backend has no raw per-Session artifact receive the endpoint's existing failure; SQLite support remains separate work. Command availability before a Session's first turn is separate work. diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md index 88d5995ad4..a86c003211 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md @@ -10,13 +10,13 @@ Session 导出需要一个稳定的 Session 级外显入口,以及语义等价 ## Decision -`@deepseek-ai/dsh-session-export` 注册 Web 专用的 `/export` 用户命令,并提供浏览器 `ctx.sessionExport` 控制器。该命令记录普通的 `command/run` 和 `command/done`;`command.execute` 返回成功结果后,`dsh-client-ui-command` 会发布本地确认,请求当前浏览器的控制器下载 ApiProxy 现有的 `GET /api/session.export` ZIP。其他客户端会渲染广播的命令节点,但不会重复执行浏览器副作用。Session Header 中 111×32 的 `Session log` 胶囊按钮会直接调用该控制器。因此,两种入口共用同一个 Host 端点、浏览器保存操作、进行中状态、错误处理和 Modal。 +`@deepseek-ai/dsh-session-export` 注册 Web 专用的 `/export` 用户命令,并提供浏览器 `ctx.sessionExport` 控制器。该命令记录普通的 `command/run` 和 `command/done`;`command.execute` 返回成功结果后,`dsh-client-ui-command` 会发布本地确认,请求当前浏览器的控制器下载 ApiProxy 现有的 `GET /api/session.export` ZIP。其他客户端会渲染广播的命令节点,但不会重复执行浏览器副作用。Session Header 中 111×32 的 `Session log` 胶囊按钮会直接调用该控制器。两种入口通过 `HEAD` 预检获得准备阶段错误,再把 GET URL 交给浏览器下载管理器,因此 JavaScript 不会缓冲 ZIP;两种入口共用进行中状态和 Modal。 -Header 贡献占用最右侧的 `conversation.session.header.utilities` 列表,渲染带尾部下载图标的 `Session log` 文字 capsule 和共享 Modal。标题旁的 `conversation.session.header.actions` 列表继续承载模式、Subagent 和 Task 配置项,挂载 Session export 不会改变它们的顺序或位置。导出贡献不观察 Session 历史。逐 Session 控制器会折叠并发操作,在插件释放时取消活动 fetch,忽略释放后的迟到请求,并在请求后来完成时保留用户已经关闭弹窗的状态。 +Header 贡献占用最右侧的 `conversation.session.header.utilities` 列表,渲染带尾部下载图标的 `Session log` 文字 capsule 和共享 Modal。标题旁的 `conversation.session.header.actions` 列表继续承载模式、Subagent 和 Task 配置项,挂载 Session export 不会改变它们的顺序或位置。导出贡献不观察 Session 历史。逐 Session 控制器会折叠并发操作,在插件释放时取消活动预检,忽略释放后的迟到请求,并在请求后来完成时保留用户已经关闭弹窗的状态。 ZIP 端点与持久化 `readRaw` 能力仍由 `dsh-host-apiproxy` 和持久化包拥有。端点会在读取工件前 flush 活动的根 Session,因此本地确认不会早于持久命令生命周期行。本包不序列化 Session 事件、不写 Host 文件、不交付 Host 路径,也不实现 SQLite 回退。 -本包通过 `tsconfig.host.json` 编译 Host 命令和 invariant,`tsconfig.client.json` 则负责浏览器控制器、Header 操作、Modal 及其带编译面后缀的测试。仓库的 Host 和 Client 聚合只引用对应项目,因此两种不兼容的 Cordis `Context` 合并不会进入同一个 TypeScript program。 +本包是普通的 Client 聚合项目。单一 `tsconfig.json` 会一起编译 Node loader 入口与浏览器贡献;Host 侧测试仍通过源码入口验证命令与 invariant。 ## Alternatives considered @@ -28,4 +28,4 @@ ZIP 端点与持久化 `readRaw` 能力仍由 `dsh-host-apiproxy` 和持久化 ## Consequences -Header 操作与 `/export` 会下载同一个 ZIP,并显示相同反馈。已执行命令保留在持久文本记录中,且不创建模型轮次。持久化后端没有逐 Session 原始工件时,用户会收到端点现有的失败;SQLite 支持保留为独立工作。Session 首轮前的命令可用性属于独立工作。 +Header 操作与 `/export` 会下载同一个 ZIP,并显示相同反馈。已执行命令保留在持久文本记录中,且不创建模型轮次。预检会报告流式传输开始前发现的失败;浏览器消费 GET 时发生的失败仍属于浏览器下载失败。持久化后端没有逐 Session 原始工件时,用户会收到端点现有的失败;SQLite 支持保留为独立工作。Session 首轮前的命令可用性属于独立工作。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4e3e2c17b5..a0b34e53b9 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -52,6 +52,11 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom } async function ensureSeedOpen(page: Page): Promise { + const welcome = page.locator('[class*="onboardingOverlay"]') + if (await welcome.count() > 0) { + await welcome.getByRole('button').click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + } const chat = page.getByRole('tab', { name: 'Chat', exact: true }) // Search is a collapsed header action; expand it so the input is actionable. const searchButton = page.getByRole('button', { name: 'Search sessions' }) @@ -297,7 +302,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32) const responsePromise = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) + response.request().method() === 'HEAD' + && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await exportButton.click() const response = await responsePromise @@ -315,17 +321,61 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(content).toContain('FIRST_DONE') await dialog.getByText('Close', { exact: true }).click() - const input = page.locator('textarea').first() - const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) - await input.fill('/export') - await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) - await input.press('Enter') - const slashDownload = await slashDownloadPromise - expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) - await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 }) - await page.getByRole('dialog', { name: 'Session download started' }) - .getByText('Close', { exact: true }).click() - }, 60_000) + const observer = await newEnglishPage(browser) + const observerTripwire = watchConsole(observer) + const observerSlotErrors: string[] = [] + let observerDownloads = 0 + observer.on('download', () => { observerDownloads += 1 }) + observer.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + observerSlotErrors.push(message.text()) + } + }) + const observerSessionBaseline = baselineResponse(observer, 'session.list') + const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list') + const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([ + observer.goto(scaffold.baseUrl, { waitUntil: 'load' }), + observerSessionBaseline, + observerWorkspaceBaseline, + ]) + await Promise.all([ + assertBaselineSucceeded(observerSessionResponse, 'observer session.list'), + assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'), + ]) + await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + await ensureSeedOpen(observer) + + try { + const input = page.locator('textarea').first() + const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await input.fill('/export') + await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) + await input.press('Enter') + const slashDownload = await slashDownloadPromise + expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) + const slashFiles = unzipSync(await readFile(await slashDownload.path())) + const slashContent = strFromU8(slashFiles['session.jsonl'] as Uint8Array) + const slashEvents = parseSessionLog(slashContent) + const exportRun = slashEvents.findLast(event => event.type === 'command/run' && event.data.name === 'export') + if (exportRun?.type !== 'command/run') throw new Error('slash ZIP has no export command/run') + const exportDone = slashEvents.find(event => + event.type === 'command/done' && event.data.commandId === exportRun.data.commandId) + expect(exportDone?.type).toBe('command/done') + await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 }) + await page.getByRole('dialog', { name: 'Session download started' }) + .getByText('Close', { exact: true }).click() + await observer.getByText('Session log download requested.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(observerDownloads).toBe(0) + expect(await observer.getByRole('dialog', { name: 'Session download started' }).count()).toBe(0) + expect({ + pageErrors: observerTripwire.pageErrors, + slotErrors: observerSlotErrors, + warnings: observerTripwire.warnings, + }).toEqual({ pageErrors: [], slotErrors: [], warnings: [] }) + } finally { + await observer.close() + } + }, 120_000) it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e59157b743..30a04ad21e 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: 1fa5d6cd38a18303857f4132ea0839822183f76b -README.zh.md: 1af0a17a0a14cf135f7b8b70089d610a1ab71d96 +README.md: 0281df76fe601eaad86cefc0dcaecc6d8999df60 +README.zh.md: ace51230d6d250f4a3b09a5212182ccf434f9249 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 1fa5d6cd38..0281df76fe 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,7 +8,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. -After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. +After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 1af0a17a0a..ace51230d6 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,7 +8,7 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 -`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。 +`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index e78d39e60a..fde1f7975d 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -12,7 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, @@ -374,10 +374,33 @@ export class CommandService extends Service implements CommandServiceContract { const result = await this.ctx.remote.commands.execute(session.sessionId, line) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } - this.ctx.emit('command/executed', session.sessionId, submittedCommandName(line), result.value.result) + this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) return { kind: 'success' } } + /** Publish the local acknowledgment without letting an observer change command admission. */ + private notifyExecuted(sessionId: SessionId, name: string, result: CommandResult): void { + const args = ['command/executed', sessionId, name, result] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(sessionId, name, result) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnExecutedListenerFailure(name, error) + }) + } + } catch (error) { + this.warnExecutedListenerFailure(name, error) + } + } + } + + /** Log one contained `command/executed` observer failure. */ + private warnExecutedListenerFailure(name: string, error: unknown): void { + this.ctx.logger.warn('client command: a command/executed listener for "%s" failed', name) + this.ctx.logger.warn(error) + } + /** * Fire-and-forget execute for the internal ('handled') paths. Outcomes are * NOT surfaced here: the host executor durably logs the command lifecycle diff --git a/packages/client/ui-command/tests/service.client.spec.ts b/packages/client/ui-command/tests/service.client.spec.ts index ccdf883d28..40801cc6a8 100644 --- a/packages/client/ui-command/tests/service.client.spec.ts +++ b/packages/client/ui-command/tests/service.client.spec.ts @@ -9,7 +9,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -525,6 +525,29 @@ describe('execute payload', () => { }]) }) + it('contains local acknowledgment listeners without changing an admitted result', async () => { + const b = await bench({ execute: () => Promise.resolve({ matched: true }) }) + await b.warm(proj('s1')) + const outcome = b.source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + const syncFailure = new Error('sync observer failed') + const asyncFailure = new Error('async observer failed') + const after = vi.fn() + const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined) + b.ctx.on('command/executed', () => { throw syncFailure }) + const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void + b.ctx.on('command/executed', rejectingListener) + b.ctx.on('command/executed', after) + + await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' }) + expect(after).toHaveBeenCalledOnce() + await Promise.resolve() + await Promise.resolve() + expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal') + expect(warn).toHaveBeenCalledWith(syncFailure) + expect(warn).toHaveBeenCalledWith(asyncFailure) + }) + it('maps matched:false to an error outcome and a matched bare result to success', async () => { const claimOf = async (opts: BenchOptions) => { const b = await bench(opts) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f0409bebe2..6eb03f5c0f 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: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8 -README.zh.md: 17bbad0094bfac49d63d6076a01d4c5cd2c5aa6b +README.md: a40cc52e5caf1e4557d17e9fd2c1ac589dce30dd +README.zh.md: 9dbc1cc482d1a0e8756eaa0dab3f2c0e62c07336 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 059c3eacbc..a40cc52e5c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ 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 no other domain's 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. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed. -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). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 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-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). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 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`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 17bbad0094..9dbc1cc482 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ 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 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index 8a5b371e7f..778225c0cc 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -1,5 +1,5 @@ /** - * downloads domain zod schemas. The GET download surface has no wire + * downloads domain zod schemas. The 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 diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 4e8800348a..697171ec53 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -249,7 +249,7 @@ 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): + // No-envelope read channels (SSE GET 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)) @@ -257,14 +257,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { 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') { + if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { // 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) + const response = await api.downloads.sessionLog(parsed.data, req.signal) + if (req.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) } if (req.method !== 'POST' || !path.startsWith('/api/')) { diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index a766c4b4eb..1c0a818bdd 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -152,6 +152,30 @@ describe('session.export download endpoint', () => { expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) }) + it('preflights root preparation through HEAD without streaming a body', async () => { + const readRaw = vi.fn(async () => artifact('session-root')) + const api = await buildApi({}, [], { readRaw }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + 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') + expect(response.body).toBeNull() + expect(readRaw).toHaveBeenCalledOnce() + }) + + it('returns a bodyless preparation error from HEAD', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(404) + expect(response.body).toBeNull() + }) + it('uses the resolved compression level for ZIP entries', async () => { const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) diff --git a/packages/session-query/session-export/README.i18n.yaml b/packages/session-query/session-export/README.i18n.yaml index 25c992f74d..65ab7c195b 100644 --- a/packages/session-query/session-export/README.i18n.yaml +++ b/packages/session-query/session-export/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/session-query/session-export/README.md -README.md: 3df11c1132715dc590d50b53588f8875015f237b -README.zh.md: 008cf433df1104c9f7e45cd04c0e0f256e3682fb +README.md: 4abee799c51d50342421ff594e88b03a11e785fa +README.zh.md: 9b3ab129c132f797bbede8d8676bc8af954c1f90 diff --git a/packages/session-query/session-export/README.md b/packages/session-query/session-export/README.md index 3df11c1132..4abee799c5 100644 --- a/packages/session-query/session-export/README.md +++ b/packages/session-query/session-export/README.md @@ -11,7 +11,7 @@ Web Session-log download control over the host-streamed ZIP endpoint owned by `d | `/export` | Record a human-command lifecycle; the submitting browser receives the local execution acknowledgment and downloads `GET /api/session.export?sessionId=&includeDescendants=true`. | | `/export ` | Return an error. Browser downloads choose their destination through the browser's ordinary download behavior. | -The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly, so both entry paths share in-flight collapsing, cancellation on plugin disposal, HTTP error handling, browser save behavior, and the same Modal. +The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly. Both entry paths issue a `HEAD` preflight, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript; they share in-flight collapsing, cancellation of the preflight on plugin disposal, preparation-error handling, browser save behavior, and the same Modal. The Host download endpoint flushes a live root Session before `readRaw`, so a slash-triggered ZIP includes the `command/run` and `command/done` pair whose acknowledgment started the download. Cold persisted Sessions require no flush. @@ -46,3 +46,4 @@ None. The log-only command lifecycle and browser download do not change the deri - The download endpoint requires a persistence backend with a per-Session raw artifact. The shipped JSONL backend supports plaintext and zstd artifacts; SQLite export is not included in this change. - This is a browser download, not a Host-path writer. The browser chooses the local destination; no Host path or native folder action is returned. +- The preflight reports failures found before ZIP streaming starts. A descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the modal. diff --git a/packages/session-query/session-export/README.zh.md b/packages/session-query/session-export/README.zh.md index 008cf433df..9b3ab129c1 100644 --- a/packages/session-query/session-export/README.zh.md +++ b/packages/session-query/session-export/README.zh.md @@ -11,7 +11,7 @@ Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流 | `/export` | 记录一组用户命令生命周期;提交命令的浏览器收到本地执行确认后,下载 `GET /api/session.export?sessionId=&includeDescendants=true`。 | | `/export ` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 | -该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器,因此两种入口共用并发折叠、插件释放时取消、HTTP 错误处理、浏览器保存行为和同一个 Modal。 +该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器。两种入口都会先发出 `HEAD` 预检,再把 GET URL 交给浏览器下载管理器,JavaScript 不会缓冲 ZIP;它们共用并发折叠、插件释放时取消预检、准备阶段错误处理、浏览器保存行为和同一个 Modal。 Host 下载端点会在 `readRaw` 前 flush 活动的根 Session,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对。冷持久化 Session 不需要 flush。 @@ -46,3 +46,4 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co - 下载端点要求持久化后端具有逐 Session 原始工件。随附 JSONL 后端支持明文和 zstd 工件;本次改动不包含 SQLite 导出。 - 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。 +- 预检只报告 ZIP 开始流式传输前发现的失败。浏览器接受 GET 后发生的子 Session 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-export/src/client/Dialog.tsx b/packages/session-query/session-export/src/client/Dialog.tsx index f829f598e5..5397781852 100644 --- a/packages/session-query/session-export/src/client/Dialog.tsx +++ b/packages/session-query/session-export/src/client/Dialog.tsx @@ -12,7 +12,7 @@ export interface SessionExportDialogInjected { } export type SessionExportDialogProps = - PropsRuntime<'conversation.session.header.actions'> + PropsRuntime<'conversation.session.header.utilities'> & PropsLocale & InjectFace diff --git a/packages/session-query/session-export/src/client/HeaderAction.module.css b/packages/session-query/session-export/src/client/HeaderAction.module.css index 03cf469b0e..288e7f9bae 100644 --- a/packages/session-query/session-export/src/client/HeaderAction.module.css +++ b/packages/session-query/session-export/src/client/HeaderAction.module.css @@ -1,4 +1,3 @@ -/* The 111 px design width is a floor so translated labels do not clip. */ .sessionLogButton { display: inline-flex; align-items: center; diff --git a/packages/session-query/session-export/src/client/controller.ts b/packages/session-query/session-export/src/client/controller.ts index 15b260c7b2..c983e98561 100644 --- a/packages/session-query/session-export/src/client/controller.ts +++ b/packages/session-query/session-export/src/client/controller.ts @@ -18,7 +18,7 @@ export interface SessionExportDownloadState { } type Fetch = (input: string | URL, init?: RequestInit) => Promise -type Save = (blob: Blob, filename: string) => void +type Save = (url: string, filename: string) => void const INITIAL: SessionExportDownloadState = { bySession: {} } @@ -32,17 +32,15 @@ export function sessionLogZipFilename(sessionId: SessionId): string { } /** - * Trigger a browser save without copying the response blob. - * @param blob - complete ZIP response body. + * Hand a Host download URL to the browser download manager. + * @param url - same-origin Host download URL. * @param filename - browser download filename. */ -export function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob) +export function downloadUrl(url: string, filename: string): void { const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() - setTimeout(() => { URL.revokeObjectURL(url) }, 0) } /** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ @@ -69,7 +67,7 @@ export class SessionExportDownloadController { */ constructor( private readonly fetcher: Fetch = (input, init) => fetch(input, init), - private readonly save: Save = downloadBlob, + private readonly save: Save = downloadUrl, ) {} /** @@ -89,15 +87,6 @@ export class SessionExportDownloadController { return done } - /** - * Present a command failure without issuing an HTTP request. - * @param sessionId - Session whose modal reports the failure. - * @param error - stable command failure text. - */ - fail(sessionId: SessionId, error: string): void { - this.publish(sessionId, { open: true, status: 'error', error }) - } - /** * Close one Session's dialog without cancelling an in-flight browser download. * @param sessionId - Session whose modal closes. @@ -125,12 +114,12 @@ export class SessionExportDownloadController { const url = new URL('/api/session.export', hostBase()) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') - const response = await this.fetcher(url, { signal }) + const response = await this.fetcher(url, { method: 'HEAD', signal }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - this.save(await response.blob(), sessionLogZipFilename(sessionId)) + this.save(url.toString(), sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'success', error: null }) } catch (error: unknown) { diff --git a/packages/session-query/session-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-export/tests/client-apply.client.spec.tsx index 033091dc7d..0049d2cb7f 100644 --- a/packages/session-query/session-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-export/tests/client-apply.client.spec.tsx @@ -43,12 +43,10 @@ describe('session-export browser plugin', () => { expect(entry?.component).toBe(SessionExportHeader) expect(entry?.options).toMatchObject({ id: 'session-export' }) const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionExportDialogInjected)() - b.ctx.sessionExport.fail(SID, 'failed') + await injected.request(SID) expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.open).toBe(false) - await injected.request(SID) - expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') await b.fiber.dispose() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) diff --git a/packages/session-query/session-export/tests/command.host.spec.ts b/packages/session-query/session-export/tests/command.client.spec.ts similarity index 100% rename from packages/session-query/session-export/tests/command.host.spec.ts rename to packages/session-query/session-export/tests/command.client.spec.ts diff --git a/packages/session-query/session-export/tests/controller.client.spec.ts b/packages/session-query/session-export/tests/controller.client.spec.ts index 21f1ab24b3..5106b0ca72 100644 --- a/packages/session-query/session-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-export/tests/controller.client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - downloadBlob, SessionExportDownloadController, sessionLogZipFilename, + downloadUrl, SessionExportDownloadController, sessionLogZipFilename, } from '../src/client/controller.ts' const SID = 'session-export-controller' as SessionId @@ -25,9 +25,12 @@ describe('SessionExportDownloadController', () => { expect(url.pathname).toBe('/api/session.export') expect(url.searchParams.get('sessionId')).toBe(SID) expect(url.searchParams.get('includeDescendants')).toBe('true') + expect(init.method).toBe('HEAD') expect(init.signal).toBeInstanceOf(AbortSignal) - expect(save).toHaveBeenCalledWith(expect.any(Object), 'dsh-session-session-export-controller.zip') - expect((save.mock.calls[0]?.[0] as Blob).size).toBe(3) + expect(save).toHaveBeenCalledWith( + url.toString(), + 'dsh-session-session-export-controller.zip', + ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ open: true, status: 'success', error: null, }) @@ -50,7 +53,7 @@ describe('SessionExportDownloadController', () => { controller.dismiss(SID) }) - it('publishes HTTP, transport, and command failures without leaking rejections', async () => { + it('publishes HTTP and transport failures without leaking rejections', async () => { const http = new SessionExportDownloadController( async () => new Response('backend unavailable', { status: 500 }), vi.fn(), ) @@ -65,8 +68,6 @@ describe('SessionExportDownloadController', () => { await transport.download(SID) expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline') - transport.fail(SID, 'command failed') - expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('command failed') transport.dismiss('absent' as SessionId) const emptyDetail = new SessionExportDownloadController( @@ -102,14 +103,14 @@ describe('SessionExportDownloadController', () => { vi.stubGlobal('location', { origin: 'null' }) const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:default') - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const controller = new SessionExportDownloadController() await controller.download(SID) expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal') + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' }) + expect(click).toHaveBeenCalledOnce() }) it('defaults dialog openness when state is externally cleared before settlement', async () => { @@ -132,19 +133,14 @@ describe('SessionExportDownloadController', () => { }) describe('browser download helpers', () => { - it('sanitizes the archive filename and revokes the object URL after the click', () => { - vi.useFakeTimers() - const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session') - const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + it('sanitizes the archive filename and hands the URL to a download anchor', () => { const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip') - downloadBlob(new Blob(['zip']), 'archive.zip') - expect(create).toHaveBeenCalledOnce() + downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip') expect(click).toHaveBeenCalledOnce() - expect(revoke).not.toHaveBeenCalled() - vi.runAllTimers() - expect(revoke).toHaveBeenCalledWith('blob:session') - vi.useRealTimers() + const anchor = click.mock.instances[0] as HTMLAnchorElement + expect(anchor.href).toBe('http://host/api/session.export?sessionId=a') + expect(anchor.download).toBe('archive.zip') }) }) diff --git a/packages/session-query/session-export/tests/dialog.client.spec.tsx b/packages/session-query/session-export/tests/dialog.client.spec.tsx index cdf2f55fab..bea21800e9 100644 --- a/packages/session-query/session-export/tests/dialog.client.spec.tsx +++ b/packages/session-query/session-export/tests/dialog.client.spec.tsx @@ -33,7 +33,11 @@ afterEach(cleanup) describe('SessionExportDialog', () => { it('shows a controller failure and closes it without reading Session history', async () => { const b = bench() - act(() => { b.controller.fail(SID, 'toolbar failed') }) + act(() => { + b.controller.store.set({ + bySession: { [SID]: { open: true, status: 'error', error: 'toolbar failed' } }, + }) + }) const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' }) expect(dialog.textContent).toContain('toolbar failed') const close = b.view.getAllByRole('button', { name: 'Close' })[0] @@ -57,7 +61,11 @@ describe('SessionExportDialog', () => { it('uses fallback copy when a failure has no detail', async () => { const b = bench() - act(() => { b.controller.fail(SID, '') }) + act(() => { + b.controller.store.set({ + bySession: { [SID]: { open: true, status: 'error', error: '' } }, + }) + }) const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' }) expect(dialog.textContent).toContain('Could not start the Session export.') const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1) diff --git a/packages/session-query/session-export/tests/invariant.host.spec.ts b/packages/session-query/session-export/tests/invariant.client.spec.ts similarity index 100% rename from packages/session-query/session-export/tests/invariant.host.spec.ts rename to packages/session-query/session-export/tests/invariant.client.spec.ts diff --git a/packages/session-query/session-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-export/tests/loader-composition.client.spec.ts similarity index 94% rename from packages/session-query/session-export/tests/loader-composition.host.spec.ts rename to packages/session-query/session-export/tests/loader-composition.client.spec.ts index c82286ea6c..de0336d59c 100644 --- a/packages/session-query/session-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-export/tests/loader-composition.client.spec.ts @@ -54,7 +54,8 @@ describe('session-export real Loader composition', () => { }) await context.loader.await() - const session = context.sessions.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } }) + const session = (context.get('sessions') as unknown as SessionStore) + .create(SessionId('loader-session-export'), { meta: { createdAt: 1 } }) const agent = { session, status: 'idle', options: {} } as unknown as Agent expect(context.commands.list(agent)).toContainEqual({ name: 'export', description: 'Download this Session log as a ZIP archive', diff --git a/packages/session-query/session-export/tsconfig.client.json b/packages/session-query/session-export/tsconfig.client.json deleted file mode 100644 index e664af1549..0000000000 --- a/packages/session-query/session-export/tsconfig.client.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" - }, - "include": [ - "src/client", - "src/css-modules.d.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../client/locale" }, - { "path": "../../client/runtime" }, - { "path": "../../client/ui-command" }, - { "path": "../../client/ui-conversation" }, - { "path": "../../client/ui-primitives" }, - { "path": "../../client/ui-slots" } - ] -} diff --git a/packages/session-query/session-export/tsconfig.host.json b/packages/session-query/session-export/tsconfig.host.json deleted file mode 100644 index 4b5036bf0b..0000000000 --- a/packages/session-query/session-export/tsconfig.host.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" - }, - "files": [ - "src/index.ts", - "src/invariant.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../support/invariants" } - ] -} diff --git a/packages/session-query/session-export/tsconfig.json b/packages/session-query/session-export/tsconfig.json index 2a0b0e33f7..7bd930dc5f 100644 --- a/packages/session-query/session-export/tsconfig.json +++ b/packages/session-query/session-export/tsconfig.json @@ -1,7 +1,21 @@ { - "files": [], + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], "references": [ - { "path": "./tsconfig.host.json" }, - { "path": "./tsconfig.client.json" } + { "path": "../../../vendor/cordis" }, + { "path": "../../interaction/commands" }, + { "path": "../../client/locale" }, + { "path": "../../client/runtime" }, + { "path": "../../client/ui-command" }, + { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-slots" }, + { "path": "../../support/invariants" } ] } diff --git a/tsconfig.client.json b/tsconfig.client.json index 53bb5868b2..b010962cfe 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,7 +81,7 @@ { "path": "./packages/client/ui-plugin-config" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, - { "path": "./packages/session-query/session-export/tsconfig.client.json" }, + { "path": "./packages/session-query/session-export" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 4b40cf9fd8..3e25c9c510 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -140,7 +140,6 @@ { "path": "./packages/session/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, - { "path": "./packages/session-query/session-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, { "path": "./packages/credentials/credentials" },